Compare commits

...

165 Commits
v4.4.1 ... v5.0

Author SHA1 Message Date
Koitharu
85065c57a1 Translated using Weblate (Russian)
Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (Portuguese)

Currently translated at 95.4% (415 of 435 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-04-26 15:55:27 +03:00
GpixeL
75e130b97c Translated using Weblate (Indonesian)
Currently translated at 97.6% (424 of 434 strings)

Co-authored-by: GpixeL <gamesfire313@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-04-26 15:55:27 +03:00
Luiz-bro
df99eec429 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (434 of 434 strings)

Co-authored-by: Luiz-bro <luiznneto1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2023-04-26 15:55:27 +03:00
Allan Nordhøy
3a40820991 Translated using Weblate (Norwegian Bokmål)
Currently translated at 78.1% (339 of 434 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
Translation: Kotatsu/Strings
2023-04-26 15:55:27 +03:00
gallegonovato
27bd2e74ca Translated using Weblate (Spanish)
Currently translated at 100.0% (434 of 434 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-04-26 15:55:27 +03:00
Koitharu
a308688a2e Misc ui fixes 2023-04-26 14:48:49 +03:00
Koitharu
259c845912 Fix chapters counter 2023-04-26 09:45:01 +03:00
Koitharu
a89ff4d15d Refactoring 2023-04-23 16:25:31 +03:00
Koitharu
3ed9ed8cab Fix text on sync authorization activity 2023-04-22 15:25:57 +03:00
Koitharu
40b9577e69 Reduce number of @Singleton instances 2023-04-22 14:56:23 +03:00
Koitharu
b87ae19712 Update parsers 2023-04-20 18:10:42 +03:00
Koitharu
1dc7e61dbd Migrate to PendingIntentCompat 2023-04-20 18:09:24 +03:00
Koitharu
7bd1affe5e Update parsers 2023-04-19 19:42:44 +03:00
Eric
068196b48b Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (433 of 433 strings)

Co-authored-by: Eric <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-04-19 19:19:41 +03:00
Zero O
89ed09fd09 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (433 of 433 strings)

Co-authored-by: Zero O <godarms2010@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-04-19 19:19:41 +03:00
Koitharu
f9b233d8c0 Update version 2023-04-19 19:19:19 +03:00
Koitharu
9017dae834 Merge branch 'devel' of github.com:KotatsuApp/Kotatsu into devel 2023-04-19 18:20:13 +03:00
Koitharu
aabae06515 Improve image loading 2023-04-19 18:17:39 +03:00
Dmitriy Shishkov
ad530fe55d Update shikimori scrobbler to new domain (#344)
Shikimori have moved to a new domain – Shikimori.me. we need to use it instead of .one
2023-04-17 18:55:32 +03:00
Koitharu
703a5358c2 Update shikimori domain 2023-04-17 18:54:07 +03:00
Koitharu
4824a95375 Update screenshots 2023-04-17 15:55:40 +03:00
Koitharu
d266ffddd3 Improve tags highlighter 2023-04-17 15:34:56 +03:00
Koitharu
fd7e7eb974 Translated using Weblate (Russian)
Currently translated at 100.0% (433 of 433 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-04-17 15:18:25 +03:00
Koitharu
ca3e789340 Merge remote-tracking branch 'weblate/devel' into devel 2023-04-17 15:11:22 +03:00
kaajjo
94ca2aae89 Translated using Weblate (Russian)
Currently translated at 97.9% (424 of 433 strings)

Co-authored-by: kaajjo <claymanoff@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-04-17 15:07:32 +03:00
tryvseu
0aad55eea0 Translated using Weblate (Norwegian Nynorsk)
Currently translated at 88.2% (382 of 433 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 87.5% (379 of 433 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 77.3% (335 of 433 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 80.5% (343 of 426 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 62.5% (5 of 8 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 34.5% (147 of 426 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 37.5% (3 of 8 strings)

Co-authored-by: tryvseu <tryvseu@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/nn/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nn/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-04-17 15:05:47 +03:00
Koitharu
ee95679c60 Remove stableIds from reader 2023-04-17 14:46:09 +03:00
Koitharu
21bcb293f5 Fix domain validator 2023-04-17 14:11:35 +03:00
Koitharu
648ee3c763 Fix response closing on mirror switch 2023-04-17 14:07:42 +03:00
Eric
ac8283d4af Translated using Weblate (Chinese (Simplified))
Currently translated at 97.6% (423 of 433 strings)

Co-authored-by: Eric <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-04-17 13:44:45 +03:00
Макар Разин
ae460720e3 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (433 of 433 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (433 of 433 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (433 of 433 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-04-17 13:44:45 +03:00
Allan Nordhøy
e66eedf0a1 Translated using Weblate (English)
Currently translated at 100.0% (433 of 433 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
Translation: Kotatsu/Strings
2023-04-17 13:44:45 +03:00
gallegonovato
e6891cc3ba Translated using Weblate (Spanish)
Currently translated at 100.0% (433 of 433 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (433 of 433 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-04-17 13:44:45 +03:00
kaajjo
5b047b616a Translated using Weblate (Russian)
Currently translated at 97.9% (424 of 433 strings)

Co-authored-by: kaajjo <claymanoff@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-04-17 13:44:45 +03:00
J. Lavoie
d3c0d89fe0 Translated using Weblate (French)
Currently translated at 100.0% (433 of 433 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-04-17 13:44:45 +03:00
kuragehime
ccebe2660f Translated using Weblate (Japanese)
Currently translated at 100.0% (426 of 426 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-04-17 13:44:45 +03:00
tryvseu
2efe739a43 Translated using Weblate (Norwegian Nynorsk)
Currently translated at 80.5% (343 of 426 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 62.5% (5 of 8 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 34.5% (147 of 426 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 37.5% (3 of 8 strings)

Co-authored-by: tryvseu <tryvseu@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/nn/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nn/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-04-17 13:44:45 +03:00
Paper Jack
3919884723 Translated using Weblate (Italian)
Currently translated at 99.2% (423 of 426 strings)

Co-authored-by: Paper Jack <paperjack@tutanota.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2023-04-17 13:44:45 +03:00
Eric
5407587de2 Translated using Weblate (Chinese (Simplified))
Currently translated at 97.6% (423 of 433 strings)

Co-authored-by: Eric <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-04-17 12:44:43 +02:00
Макар Разин
29ead727bb Translated using Weblate (Ukrainian)
Currently translated at 100.0% (433 of 433 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (433 of 433 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (433 of 433 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-04-17 12:44:42 +02:00
Allan Nordhøy
cfbe394bfb Translated using Weblate (English)
Currently translated at 100.0% (433 of 433 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
Translation: Kotatsu/Strings
2023-04-17 12:44:42 +02:00
gallegonovato
9914b9a312 Translated using Weblate (Spanish)
Currently translated at 100.0% (433 of 433 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (433 of 433 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-04-17 12:44:41 +02:00
kaajjo
6548a6f1fe Translated using Weblate (Russian)
Currently translated at 97.9% (424 of 433 strings)

Co-authored-by: kaajjo <claymanoff@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-04-17 12:44:41 +02:00
J. Lavoie
5a22e9b0e6 Translated using Weblate (French)
Currently translated at 100.0% (433 of 433 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-04-17 12:44:40 +02:00
kuragehime
85ce118141 Translated using Weblate (Japanese)
Currently translated at 100.0% (426 of 426 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-04-17 12:44:40 +02:00
tryvseu
d4c3a815a1 Translated using Weblate (Norwegian Nynorsk)
Currently translated at 88.2% (382 of 433 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 87.5% (379 of 433 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 77.3% (335 of 433 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 80.5% (343 of 426 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 62.5% (5 of 8 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 34.5% (147 of 426 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 37.5% (3 of 8 strings)

Co-authored-by: tryvseu <tryvseu@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/nn/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nn/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-04-17 12:44:39 +02:00
Paper Jack
e1b9f41fe3 Translated using Weblate (Italian)
Currently translated at 99.2% (423 of 426 strings)

Co-authored-by: Paper Jack <paperjack@tutanota.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2023-04-17 12:44:39 +02:00
Koitharu
55f2f80486 Enable obfucsation to reduce apk size 2023-04-17 13:44:25 +03:00
Koitharu
628944a4f2 Fix 'closed' error 2023-04-17 13:06:14 +03:00
Koitharu
7d0c50d58e Filter GitHub assets by type 2023-04-17 12:58:26 +03:00
Koitharu
c989061576 Fix strict mode warning 2023-04-17 12:14:17 +03:00
Koitharu
5896d7abe7 Fix gc database during sync 2023-04-17 09:56:17 +03:00
Koitharu
1999f6f1a1 Suppress R8 errors 2023-04-15 17:56:48 +03:00
Koitharu
745f0adf5b Show if sync disabled 2023-04-15 17:18:10 +03:00
Koitharu
32b1ee9e7b Update feed by pull-to-refresh 2023-04-15 17:02:46 +03:00
Koitharu
85710acb3a Fix synchronization 2023-04-15 16:57:43 +03:00
Koitharu
ffd31dbea9 Update dependencies 2023-04-15 13:45:43 +03:00
Koitharu
c4d8cd81b2 Update gradle 2023-04-15 13:03:39 +03:00
Koitharu
c4ba311087 Handle nested scroll state in shelf 2023-04-14 18:22:40 +03:00
Koitharu
277d575485 Fix downloading manga into existing cbz 2023-04-13 19:38:17 +03:00
Koitharu
f32ff00b68 Merge branch 'release/5' into devel 2023-04-12 20:08:33 +03:00
Koitharu
bd5b6beb72 Add option to show favourite category on shelf 2023-04-12 20:07:29 +03:00
Koitharu
c8053b2eb6 Import backup from import dialog 2023-04-12 19:50:12 +03:00
Koitharu
938be67cd3 Update about settings 2023-04-12 19:20:58 +03:00
Koitharu
f608dd3078 Update parsers 2023-04-12 18:56:44 +03:00
Koitharu
72169e71ce Open bookmarks in incognito mode 2023-04-12 18:52:02 +03:00
Koitharu
8ce5e7eccf Fix bookmarks thumbnails 2023-04-12 10:20:16 +03:00
Koitharu
cfd39e615a Fix background colors #339 2023-04-12 09:17:15 +03:00
Koitharu
8718b8781d Update metadata 2023-04-08 09:09:03 +03:00
Koitharu
d18c90c31a Update readme 2023-04-08 09:04:02 +03:00
Koitharu
16c3e61984 Respect system animation disabling #341 2023-04-07 18:37:39 +03:00
Koitharu
dba506cb42 Use Chrome useragent for authorization 2023-04-07 18:10:06 +03:00
Koitharu
0186517175 Merge branch 'devel' into release/5 2023-04-06 20:12:48 +03:00
Koitharu
f63ccf2e90 Update readme 2023-04-06 20:09:48 +03:00
Koitharu
dfb88feaf0 Update parsers 2023-04-06 19:48:22 +03:00
Koitharu
c53ee01af5 LocalMangaUtil 2023-04-06 19:46:04 +03:00
tryvseu
149b171eda Added translation using Weblate (Norwegian Nynorsk)
Added translation using Weblate (Norwegian Nynorsk)

Co-authored-by: tryvseu <tryvseu@tuta.io>
2023-04-06 18:44:43 +03:00
InfinityDouki56
c70a2487d9 Translated using Weblate (Filipino)
Currently translated at 92.2% (393 of 426 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-04-06 18:44:43 +03:00
Koitharu
2e56fcb5da Enable sync 2023-04-01 17:46:43 +03:00
Koitharu
04533aa347 Fix first segment size in SegmentedBarView 2023-04-01 08:08:44 +03:00
Koitharu
d258f479b3 Smooth start-stop on smooth scrolling 2023-04-01 08:02:56 +03:00
Koitharu
b81ecda43e Fix automatic scroll speed 2023-03-31 19:29:55 +03:00
Koitharu
f42e3d7912 Do sources configuration ops in background 2023-03-31 19:26:03 +03:00
Koitharu
e1780b71ae Update parsers 2023-03-30 18:57:09 +03:00
Koitharu
57bad3814d Fix crash on reader settings 2023-03-30 18:24:25 +03:00
Koitharu
07de0c9c84 Fix crash in source settings 2023-03-30 18:19:50 +03:00
Koitharu
d8a7280b3b Fix sharing unexisting logs 2023-03-30 18:13:41 +03:00
Koitharu
7bea3caa07 Fix empty tracker logs records 2023-03-30 18:12:43 +03:00
Koitharu
bd27eb9f59 Fix local manga operations 2023-03-30 18:11:43 +03:00
Koitharu
056538a341 Merge branch 'devel' into release/5 2023-03-25 16:27:21 +02:00
Koitharu
865f335b25 Fix lint errors 2023-03-25 16:26:24 +02:00
Koitharu
6b1e89eda8 Update parsers 2023-03-25 16:04:57 +02:00
Koitharu
0dbaf919e2 Remove pages duplicates #309 2023-03-25 15:48:39 +02:00
Koitharu
c2508bbae8 Remove missing service from manifest 2023-03-25 09:56:01 +02:00
Koitharu
4c347862f8 Remove pages duplicates #309 2023-03-25 09:53:30 +02:00
Koitharu
21f6a0a8b9 Merge branch 'devel' into release/5 2023-03-25 09:34:47 +02:00
Koitharu
7431f46117 Update acra url 2023-03-25 08:59:47 +02:00
Koitharu
48ac417189 Remove referrer field 2023-03-25 08:56:41 +02:00
Koitharu
98453c34a7 Update parsers 2023-03-25 08:47:00 +02:00
Koitharu
7bec47b4d8 Automaticaly switch mirrors on network errors 2023-03-25 08:40:18 +02:00
FateXBlood
c62e29d995 Translated using Weblate (Nepali)
Currently translated at 14.0% (60 of 426 strings)

Translated using Weblate (Nepali)

Currently translated at 100.0% (8 of 8 strings)

Added translation using Weblate (Nepali)

Added translation using Weblate (Nepali)

Co-authored-by: FateXBlood <zecrofelix@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ne/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ne/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-03-25 08:15:52 +02:00
J. Lavoie
4d0bd9538b Translated using Weblate (French)
Currently translated at 100.0% (426 of 426 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-03-25 08:15:52 +02:00
ssantos
fdb4e5098e Translated using Weblate (Portuguese)
Currently translated at 98.3% (419 of 426 strings)

Translated using Weblate (Portuguese)

Currently translated at 98.3% (419 of 426 strings)

Co-authored-by: ssantos <ssantos@web.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2023-03-25 08:15:52 +02:00
Макар Разин
758d3c55d4 Translated using Weblate (Belarusian)
Currently translated at 100.0% (426 of 426 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (426 of 426 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (426 of 426 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (8 of 8 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-03-25 08:15:52 +02:00
GpixeL
f40ff12250 Translated using Weblate (Indonesian)
Currently translated at 96.2% (410 of 426 strings)

Co-authored-by: GpixeL <gamesfire313@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-03-25 08:15:52 +02:00
Koitharu
43d55cedae Refactor scroll timer 2023-03-24 15:09:23 +02:00
Koitharu
bc4dd1c507 Smooth auto scrolling #319 2023-03-21 21:00:57 +02:00
Koitharu
b45147563a Fix progress percent computatiion #327 2023-03-21 20:03:57 +02:00
Koitharu
527e11e65b Remove onBackPressed override behavior #328 2023-03-21 19:15:09 +02:00
Koitharu
9b8b6d789e Cleanup 2023-03-19 09:47:25 +02:00
Koitharu
0e4575356a Rewrite manga importer #31 2023-03-18 20:45:42 +02:00
Koitharu
4744a0a162 Support for storing local manga in directories with multiple chapters cbz 2023-03-18 15:24:56 +02:00
Koitharu
b1a94c0f34 Remove obsolete code 2023-03-15 19:57:16 +02:00
Koitharu
f38ff55aea Resolve some warinings 2023-03-15 19:06:03 +02:00
Koitharu
efc4bbacb5 Update components scope 2023-03-13 20:26:12 +02:00
Koitharu
b4e0704a3a Change PageLoader lifecycle 2023-03-13 18:14:25 +02:00
Koitharu
8294eb4ecd Fix chips icon tint 2023-03-13 17:31:18 +02:00
Koitharu
28e3f1c063 Update error details dialog 2023-03-11 17:17:41 +02:00
Koitharu
072cdc35e8 Animated splash screen 2023-03-11 16:23:45 +02:00
Koitharu
1ad64f2710 Animate memory usage view 2023-03-11 14:33:00 +02:00
Koitharu
b2266d47df Update launcher icon 2023-03-11 13:58:06 +02:00
Koitharu
c8141c6046 Got rid of AssistedInject for ViewModels 2023-03-11 12:37:00 +02:00
Koitharu
cc698cc82d Update AndroidX dependencies 2023-03-11 08:38:31 +02:00
Koitharu
472a8d9d72 Merge branch 'devel' into release/5 2023-03-11 08:21:00 +02:00
Koitharu
4f3721beea Update parsers 2023-03-09 07:46:09 +02:00
Макар Разин
346526267e Translated using Weblate (Russian)
Currently translated at 100.0% (8 of 8 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ru/
Translation: Kotatsu/plurals
2023-03-09 07:36:33 +02:00
Jeffrey
6c82c6e9f5 Translated using Weblate (Korean)
Currently translated at 83.5% (356 of 426 strings)

Co-authored-by: Jeffrey <kjw5608kjw@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ko/
Translation: Kotatsu/Strings
2023-03-09 07:36:33 +02:00
InfinityDouki56
11dabd7426 Translated using Weblate (Filipino)
Currently translated at 92.0% (392 of 426 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-03-09 07:36:33 +02:00
GpixeL
056a26b55c Translated using Weblate (Indonesian)
Currently translated at 99.7% (425 of 426 strings)

Translated using Weblate (Indonesian)

Currently translated at 98.3% (419 of 426 strings)

Translated using Weblate (Indonesian)

Currently translated at 98.3% (419 of 426 strings)

Co-authored-by: GpixeL <gamesfire313@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-03-09 07:36:33 +02:00
Felipe Nogueira
f1863ddc71 Translated using Weblate (Portuguese (Brazil))
Currently translated at 97.8% (417 of 426 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.4% (415 of 426 strings)

Co-authored-by: Felipe Nogueira <contato.fnog@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2023-03-09 07:36:33 +02:00
Koitharu
437ae4cdae Add mission import 2023-03-07 19:49:56 +02:00
ViAnh
98f5615d77 Apply suggestions from code review
Co-authored-by: Koitharu <nvasya95@gmail.com>
2023-03-07 19:45:08 +02:00
vianh
44ce3ce66d Move get cache page to background thread 2023-03-07 19:45:08 +02:00
Koitharu
080c2724cd Update parsers 2023-03-05 08:14:10 +02:00
Koitharu
43872ffe01 Upgrade AGP 2023-03-05 08:11:15 +02:00
Koitharu
5cfad9ab8a Reveal services secrets #313 #317 2023-03-05 07:30:47 +02:00
Koitharu
866f9272ef Fix handling sync server errors 2023-03-05 07:14:56 +02:00
Koitharu
c6446afab1 Merge branch 'devel' into release/5 2023-03-04 13:08:46 +02:00
Koitharu
f5a6e1e124 Merge remote-tracking branch 'weblate/devel' into devel 2023-03-04 08:45:52 +02:00
Макар Разин
5595bc6971 Translated using Weblate (Russian)
Currently translated at 100.0% (426 of 426 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-03-04 06:41:08 +01:00
Felipe Nogueira
e6ed353211 Translated using Weblate (Portuguese (Brazil))
Currently translated at 97.1% (414 of 426 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 96.0% (409 of 426 strings)

Translated using Weblate (Portuguese)

Currently translated at 94.1% (401 of 426 strings)

Co-authored-by: Felipe Nogueira <contato.fnog@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2023-03-04 06:41:08 +01:00
Eric
4e10908015 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (426 of 426 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (425 of 425 strings)

Co-authored-by: Eric <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-03-04 06:41:07 +01:00
Oğuz Ersen
087ececfdd Translated using Weblate (Turkish)
Currently translated at 100.0% (426 of 426 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (425 of 425 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-03-04 06:41:07 +01:00
J. Lavoie
c090018acd Translated using Weblate (French)
Currently translated at 100.0% (425 of 425 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-03-04 06:41:06 +01:00
gallegonovato
5f6256a5c6 Translated using Weblate (Spanish)
Currently translated at 100.0% (426 of 426 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (425 of 425 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-03-04 06:41:06 +01:00
Dpper
9e6be12707 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (424 of 424 strings)

Co-authored-by: Dpper <ruslan20020401@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-03-04 06:41:05 +01:00
Eric
737ca4a916 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (425 of 425 strings)

Co-authored-by: Eric <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-03-04 07:41:04 +02:00
Oğuz Ersen
b2958d03e4 Translated using Weblate (Turkish)
Currently translated at 100.0% (425 of 425 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-03-04 07:41:04 +02:00
J. Lavoie
af8550744f Translated using Weblate (French)
Currently translated at 100.0% (425 of 425 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-03-04 07:41:04 +02:00
gallegonovato
2f5fd71bb1 Translated using Weblate (Spanish)
Currently translated at 100.0% (425 of 425 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-03-04 07:41:04 +02:00
Dpper
271750ad93 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (424 of 424 strings)

Co-authored-by: Dpper <ruslan20020401@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-03-04 07:41:04 +02:00
Koitharu
0281c09dde Update version 2023-03-04 07:40:43 +02:00
Koitharu
c50fa8f10c Refactor error handling 2023-03-03 20:08:38 +02:00
Koitharu
f2ac3c331c Limit favourite category name length 2023-03-03 18:51:58 +02:00
Koitharu
4fc56f9786 Fix detailed list genres scrolling 2023-03-03 07:54:59 +02:00
Koitharu
da47dac3f7 Fix tags highlighter 2023-03-03 07:48:31 +02:00
Koitharu
d2afd36656 Refactor image loading requests 2023-03-03 07:26:25 +02:00
Koitharu
1316d71d3e Merge branch 'devel' into release/5 2023-03-02 18:52:59 +02:00
Koitharu
a13c498d00 Fix ThemeChooserPreference memory leak 2023-03-02 18:42:48 +02:00
Koitharu
e15934bdc6 Option to inore SSL errors 2023-03-02 18:38:11 +02:00
Koitharu
4ec50f83d2 Update parsers 2023-02-25 18:44:31 +02:00
Koitharu
d0b9412559 Revert "Highlight suspicious genres"
This reverts commit 9adf209445.
2023-02-25 17:20:06 +02:00
Koitharu
a3b22e050f Highlight suspicious genres 2023-02-25 17:19:56 +02:00
Koitharu
9adf209445 Highlight suspicious genres 2023-02-25 17:15:07 +02:00
Koitharu
672a1e9b2a Rework sources configuration screen 2023-02-23 19:47:30 +02:00
336 changed files with 6495 additions and 3804 deletions

View File

@@ -2,17 +2,12 @@
Kotatsu is a free and open source manga reader for Android. Kotatsu is a free and open source manga reader for Android.
![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/KotatsuApp/Kotatsu) ![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) ![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/KotatsuApp/Kotatsu) ![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF)](https://t.me/kotatsuapp) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5)
### Download ### Download
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" - **Recommended:** Download and install APK from **[GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest)**. Application has a built-in self-updating feature.
alt="Get it on F-Droid" - Get it on **[F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu)**. The F-Droid build may be a bit outdated and some fixes might be missing.
height="80">](https://f-droid.org/packages/org.koitharu.kotatsu)
Download APK directly from GitHub:
- **[Latest release](https://github.com/KotatsuApp/Kotatsu/releases/latest)**
### Main Features ### Main Features

View File

@@ -8,15 +8,15 @@ plugins {
android { android {
compileSdk = 33 compileSdk = 33
buildToolsVersion = '33.0.1' buildToolsVersion = '33.0.2'
namespace = 'org.koitharu.kotatsu' namespace = 'org.koitharu.kotatsu'
defaultConfig { defaultConfig {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 33 targetSdkVersion 33
versionCode 517 versionCode 538
versionName '4.4.1' versionName '5.0'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -25,15 +25,6 @@ android {
arg 'room.schemaLocation', "$projectDir/schemas".toString() arg 'room.schemaLocation', "$projectDir/schemas".toString()
} }
} }
// define this values in your local.properties file
buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', "\"${localProperty('shikimori.clientId')}\""
buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', "\"${localProperty('shikimori.clientSecret')}\""
buildConfigField 'String', 'ANILIST_CLIENT_ID', "\"${localProperty('anilist.clientId')}\""
buildConfigField 'String', 'ANILIST_CLIENT_SECRET', "\"${localProperty('anilist.clientSecret')}\""
buildConfigField 'String', 'MAL_CLIENT_ID', "\"${localProperty('mal.clientId')}\""
resValue "string", "acra_login", "${localProperty('acra.login')}"
resValue "string", "acra_password", "${localProperty('acra.password')}"
} }
buildTypes { buildTypes {
debug { debug {
@@ -53,22 +44,21 @@ android {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
} }
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString() jvmTarget = JavaVersion.VERSION_17.toString()
freeCompilerArgs += [ freeCompilerArgs += [
'-opt-in=kotlin.ExperimentalStdlibApi', '-opt-in=kotlin.ExperimentalStdlibApi',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-opt-in=kotlinx.coroutines.FlowPreview', '-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts', '-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=coil.annotation.ExperimentalCoilApi', '-opt-in=coil.annotation.ExperimentalCoilApi',
'-opt-in=com.google.android.material.badge.ExperimentalBadgeUtils',
] ]
} }
lint { lint {
abortOnError false abortOnError true
disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged' disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged'
} }
testOptions { testOptions {
@@ -87,35 +77,36 @@ afterEvaluate {
} }
} }
dependencies { dependencies {
implementation('com.github.KotatsuApp:kotatsu-parsers:f4c47b5b84') { //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:306d46ea93') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.10' implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.21'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.9.0' implementation 'androidx.core:core-ktx:1.10.0'
implementation 'androidx.activity:activity-ktx:1.6.1' implementation 'androidx.activity:activity-ktx:1.7.1'
implementation 'androidx.fragment:fragment-ktx:1.5.5' implementation 'androidx.fragment:fragment-ktx:1.5.7'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-service:2.5.1' implementation 'androidx.lifecycle:lifecycle-service:2.6.1'
implementation 'androidx.lifecycle:lifecycle-process:2.5.1' implementation 'androidx.lifecycle:lifecycle-process:2.6.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation 'androidx.recyclerview:recyclerview:1.3.0'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.8.0' implementation 'androidx.work:work-runtime-ktx:2.8.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.8.0' implementation 'com.google.android.material:material:1.8.0'
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1' kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
implementation 'androidx.room:room-runtime:2.5.0' implementation 'androidx.room:room-runtime:2.5.1'
implementation 'androidx.room:room-ktx:2.5.0' implementation 'androidx.room:room-ktx:2.5.1'
kapt 'androidx.room:room-compiler:2.5.0' kapt 'androidx.room:room-compiler:2.5.1'
implementation 'com.squareup.okhttp3:okhttp:4.10.0' implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3'
@@ -124,13 +115,13 @@ dependencies {
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'com.google.dagger:hilt-android:2.44.2' implementation 'com.google.dagger:hilt-android:2.45'
kapt 'com.google.dagger:hilt-compiler:2.44.2' kapt 'com.google.dagger:hilt-compiler:2.45'
implementation 'androidx.hilt:hilt-work:1.0.0' implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0' kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.2.2' implementation 'io.coil-kt:coil-base:2.3.0'
implementation 'io.coil-kt:coil-svg:2.2.2' implementation 'io.coil-kt:coil-svg:2.3.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2' implementation 'io.noties.markwon:core:4.6.2'
@@ -141,7 +132,7 @@ dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20220924' testImplementation 'org.json:json:20230227'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:runner:1.5.2'
@@ -151,9 +142,9 @@ dependencies {
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
androidTestImplementation 'androidx.room:room-testing:2.5.0' androidTestImplementation 'androidx.room:room-testing:2.5.1'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.14.0' androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.14.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.44.2' androidTestImplementation 'com.google.dagger:hilt-android-testing:2.45'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.44.2' kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.45'
} }

View File

@@ -1,5 +1,4 @@
-optimizationpasses 8 -optimizationpasses 8
-dontobfuscate
-assumenosideeffects class kotlin.jvm.internal.Intrinsics { -assumenosideeffects class kotlin.jvm.internal.Intrinsics {
public static void checkExpressionValueIsNotNull(...); public static void checkExpressionValueIsNotNull(...);
public static void checkNotNullExpressionValue(...); public static void checkNotNullExpressionValue(...);
@@ -10,7 +9,10 @@
} }
-keep public class ** extends org.koitharu.kotatsu.base.ui.BaseFragment -keep public class ** extends org.koitharu.kotatsu.base.ui.BaseFragment
-keep class org.koitharu.kotatsu.core.db.entity.* { *; } -keep class org.koitharu.kotatsu.core.db.entity.* { *; }
-dontwarn okhttp3.internal.platform.ConscryptPlatform -dontwarn okhttp3.internal.platform.**
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
-keep class org.koitharu.kotatsu.core.exceptions.* { *; } -keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment -keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment

View File

@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<bool name="leak_canary_add_launcher_icon" tools:node="replace">false</bool> <bool name="leak_canary_add_launcher_icon" tools:node="replace">false</bool>
<bool name="is_sync_enabled">true</bool>
</resources> </resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_type_sync" translatable="false">org.kotatsu.debug.sync</string>
<string name="sync_authority_history" translatable="false">org.koitharu.kotatsu.debug.history</string>
<string name="sync_authority_favourites" translatable="false">org.koitharu.kotatsu.debug.favourites</string>
</resources>

View File

@@ -86,7 +86,17 @@
<activity <activity
android:name="org.koitharu.kotatsu.settings.SettingsActivity" android:name="org.koitharu.kotatsu.settings.SettingsActivity"
android:exported="true" android:exported="true"
android:label="@string/settings" /> android:label="@string/settings">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kotatsu" />
<data android:host="about" />
</intent-filter>
</activity>
<activity <activity
android:name="org.koitharu.kotatsu.browser.BrowserActivity" android:name="org.koitharu.kotatsu.browser.BrowserActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
@@ -145,6 +155,9 @@
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kotatsu" /> <data android:scheme="kotatsu" />
<data android:host="shikimori-auth" />
<data android:host="anilist-auth" />
<data android:host="mal-auth" />
</intent-filter> </intent-filter>
</activity> </activity>
@@ -154,7 +167,6 @@
android:foregroundServiceType="dataSync" android:foregroundServiceType="dataSync"
android:stopWithTask="false" /> android:stopWithTask="false" />
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" /> <service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
<service android:name="org.koitharu.kotatsu.local.ui.ImportService" />
<service <service
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService" android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" /> android:permission="android.permission.BIND_REMOTEVIEWS" />
@@ -215,13 +227,13 @@
</provider> </provider>
<provider <provider
android:name="org.koitharu.kotatsu.sync.ui.favourites.FavouritesSyncProvider" android:name="org.koitharu.kotatsu.sync.ui.favourites.FavouritesSyncProvider"
android:authorities="org.koitharu.kotatsu.favourites" android:authorities="@string/sync_authority_favourites"
android:exported="false" android:exported="false"
android:label="@string/favourites" android:label="@string/favourites"
android:syncable="true" /> android:syncable="true" />
<provider <provider
android:name="org.koitharu.kotatsu.sync.ui.history.HistorySyncProvider" android:name="org.koitharu.kotatsu.sync.ui.history.HistorySyncProvider"
android:authorities="org.koitharu.kotatsu.history" android:authorities="@string/sync_authority_history"
android:exported="false" android:exported="false"
android:label="@string/history" android:label="@string/history"
android:syncable="true" /> android:syncable="true" />

View File

@@ -23,6 +23,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.utils.ext.processLifecycleScope import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import javax.inject.Inject import javax.inject.Inject
@@ -125,6 +126,7 @@ class KotatsuApp : Application(), Configuration.Provider {
.setClassInstanceLimit(LocalMangaRepository::class.java, 1) .setClassInstanceLimit(LocalMangaRepository::class.java, 1)
.setClassInstanceLimit(PagesCache::class.java, 1) .setClassInstanceLimit(PagesCache::class.java, 1)
.setClassInstanceLimit(MangaLoaderContext::class.java, 1) .setClassInstanceLimit(MangaLoaderContext::class.java, 1)
.setClassInstanceLimit(PageLoader::class.java, 1)
.penaltyLog() .penaltyLog()
.build(), .build(),
) )

View File

@@ -4,6 +4,7 @@ import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.util.Size import android.util.Size
import androidx.room.withTransaction import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@@ -34,6 +35,7 @@ import kotlin.math.roundToInt
private const val MIN_WEBTOON_RATIO = 2 private const val MIN_WEBTOON_RATIO = 2
@Reusable
class MangaDataRepository @Inject constructor( class MangaDataRepository @Inject constructor(
private val okHttpClient: OkHttpClient, private val okHttpClient: OkHttpClient,
private val db: MangaDatabase, private val db: MangaDatabase,
@@ -126,7 +128,7 @@ class MangaDataRepository @Inject constructor(
.url(url) .url(url)
.get() .get()
.tag(MangaSource::class.java, page.source) .tag(MangaSource::class.java, page.source)
.cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED) .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
.build() .build()
okHttpClient.newCall(request).await().use { okHttpClient.newCall(request).await().use {
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {

View File

@@ -3,15 +3,17 @@ package org.koitharu.kotatsu.base.domain
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.lifecycle.SavedStateHandle
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.getParcelableCompat import org.koitharu.kotatsu.utils.ext.getParcelableCompat
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
class MangaIntent private constructor( class MangaIntent private constructor(
val manga: Manga?, @JvmField val manga: Manga?,
val mangaId: Long, @JvmField val mangaId: Long,
val uri: Uri?, @JvmField val uri: Uri?,
) { ) {
constructor(intent: Intent?) : this( constructor(intent: Intent?) : this(
@@ -20,6 +22,12 @@ class MangaIntent private constructor(
uri = intent?.data, uri = intent?.data,
) )
constructor(savedStateHandle: SavedStateHandle) : this(
manga = savedStateHandle.get<ParcelableManga>(KEY_MANGA)?.manga,
mangaId = savedStateHandle[KEY_ID] ?: ID_NONE,
uri = savedStateHandle[BaseActivity.EXTRA_DATA],
)
constructor(args: Bundle?) : this( constructor(args: Bundle?) : this(
manga = args?.getParcelableCompat<ParcelableManga>(KEY_MANGA)?.manga, manga = args?.getParcelableCompat<ParcelableManga>(KEY_MANGA)?.manga,
mangaId = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE, mangaId = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE,

View File

@@ -1,7 +1,7 @@
package org.koitharu.kotatsu.base.ui package org.koitharu.kotatsu.base.ui
import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MenuItem import android.view.MenuItem
@@ -26,39 +26,49 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.base.ui.util.BaseActivityEntryPoint import org.koitharu.kotatsu.base.ui.util.BaseActivityEntryPoint
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.base.ui.util.inject
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ext.getThemeColor import org.koitharu.kotatsu.utils.ext.getThemeColor
import javax.inject.Inject
@Suppress("LeakingThis")
abstract class BaseActivity<B : ViewBinding> : abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(), AppCompatActivity(),
WindowInsetsDelegate.WindowInsetsListener { WindowInsetsDelegate.WindowInsetsListener {
@Inject private var isAmoledTheme = false
lateinit var settings: AppSettings
protected lateinit var binding: B protected lateinit var binding: B
private set private set
@Suppress("LeakingThis") @JvmField
protected val exceptionResolver = ExceptionResolver(this) protected val exceptionResolver = ExceptionResolver(this)
@Suppress("LeakingThis") @JvmField
protected val insetsDelegate = WindowInsetsDelegate(this) protected val insetsDelegate = WindowInsetsDelegate(this)
@JvmField
val actionModeDelegate = ActionModeDelegate() val actionModeDelegate = ActionModeDelegate()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).inject(this) val settings = EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).settings
isAmoledTheme = settings.isAmoledTheme
setTheme(settings.colorScheme.styleResId) setTheme(settings.colorScheme.styleResId)
if (settings.isAmoledTheme) { if (isAmoledTheme) {
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled) setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
} }
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
insetsDelegate.handleImeInsets = true insetsDelegate.handleImeInsets = true
putDataToExtras(intent)
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
onBackPressedDispatcher.addCallback(actionModeDelegate)
}
override fun onNewIntent(intent: Intent?) {
putDataToExtras(intent)
super.onNewIntent(intent)
} }
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR) @Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
@@ -102,7 +112,7 @@ abstract class BaseActivity<B : ViewBinding> :
protected fun isDarkAmoledTheme(): Boolean { protected fun isDarkAmoledTheme(): Boolean {
val uiMode = resources.configuration.uiMode val uiMode = resources.configuration.uiMode
val isNight = uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES val isNight = uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
return isNight && settings.isAmoledTheme return isNight && isAmoledTheme
} }
@CallSuper @CallSuper
@@ -131,17 +141,12 @@ abstract class BaseActivity<B : ViewBinding> :
window.statusBarColor = getThemeColor(android.R.attr.statusBarColor) window.statusBarColor = getThemeColor(android.R.attr.statusBarColor)
} }
@Suppress("DEPRECATION", "DeprecatedCallableAddReplaceWith") private fun putDataToExtras(intent: Intent?) {
@Deprecated("Should not be used") intent?.putExtra(EXTRA_DATA, intent.data)
override fun onBackPressed() { }
if ( // https://issuetracker.google.com/issues/139738913
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q && companion object {
isTaskRoot &&
supportFragmentManager.backStackEntryCount == 0 const val EXTRA_DATA = "data"
) {
finishAfterTransition()
} else {
super.onBackPressed()
}
} }
} }

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.base.ui
import android.app.Dialog import android.app.Dialog
import android.os.Bundle import android.os.Bundle
import android.util.DisplayMetrics
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@@ -15,7 +14,8 @@ import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.AppBottomSheetDialog import org.koitharu.kotatsu.base.ui.dialog.AppBottomSheetDialog
import org.koitharu.kotatsu.utils.ext.displayCompat import org.koitharu.kotatsu.utils.ext.findActivity
import org.koitharu.kotatsu.utils.ext.getDisplaySize
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() { abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
@@ -41,21 +41,20 @@ abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
): View { ): View {
val binding = onInflateView(inflater, container) val binding = onInflateView(inflater, container)
viewBinding = binding viewBinding = binding
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Enforce max width for tablets // Enforce max width for tablets
val width = resources.getDimensionPixelSize(R.dimen.bottom_sheet_width) val width = resources.getDimensionPixelSize(R.dimen.bottom_sheet_width)
if (width > 0) { if (width > 0) {
behavior?.maxWidth = width behavior?.maxWidth = width
} }
// Set peek height to 40% display height
// Set peek height to 50% display height binding.root.context.findActivity()?.getDisplaySize()?.let {
requireContext().displayCompat?.let { behavior?.peekHeight = (it.height() * 0.4).toInt()
val metrics = DisplayMetrics()
it.getRealMetrics(metrics)
behavior?.peekHeight = (metrics.heightPixels * 0.4).toInt()
} }
return binding.root
} }
override fun onDestroyView() { override fun onDestroyView() {

View File

@@ -10,6 +10,7 @@ import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
@Suppress("LeakingThis")
abstract class BaseFragment<B : ViewBinding> : abstract class BaseFragment<B : ViewBinding> :
Fragment(), Fragment(),
WindowInsetsDelegate.WindowInsetsListener { WindowInsetsDelegate.WindowInsetsListener {
@@ -19,10 +20,10 @@ abstract class BaseFragment<B : ViewBinding> :
protected val binding: B protected val binding: B
get() = checkNotNull(viewBinding) get() = checkNotNull(viewBinding)
@Suppress("LeakingThis") @JvmField
protected val exceptionResolver = ExceptionResolver(this) protected val exceptionResolver = ExceptionResolver(this)
@Suppress("LeakingThis") @JvmField
protected val insetsDelegate = WindowInsetsDelegate(this) protected val insetsDelegate = WindowInsetsDelegate(this)
protected val actionModeDelegate: ActionModeDelegate protected val actionModeDelegate: ActionModeDelegate

View File

@@ -9,12 +9,13 @@ import androidx.core.view.updatePadding
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.SettingsHeadersFragment import org.koitharu.kotatsu.settings.SettingsHeadersFragment
import javax.inject.Inject
@Suppress("LeakingThis")
@AndroidEntryPoint @AndroidEntryPoint
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(), PreferenceFragmentCompat(),
@@ -24,7 +25,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
@Inject @Inject
lateinit var settings: AppSettings lateinit var settings: AppSettings
@Suppress("LeakingThis") @JvmField
protected val insetsDelegate = WindowInsetsDelegate(this) protected val insetsDelegate = WindowInsetsDelegate(this)
override val recyclerView: RecyclerView override val recyclerView: RecyclerView
@@ -55,7 +56,6 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
) )
} }
@Suppress("UsePropertyAccessSyntax")
protected fun setTitle(title: CharSequence) { protected fun setTitle(title: CharSequence) {
(parentFragment as? SettingsHeadersFragment)?.setTitle(title) (parentFragment as? SettingsHeadersFragment)?.setTitle(title)
?: activity?.setTitle(title) ?: activity?.setTitle(title)

View File

@@ -3,16 +3,24 @@ package org.koitharu.kotatsu.base.ui
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CancellationException
import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.* import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
abstract class BaseViewModel : ViewModel() { abstract class BaseViewModel : ViewModel() {
@JvmField
protected val loadingCounter = CountedBooleanLiveData() protected val loadingCounter = CountedBooleanLiveData()
@JvmField
protected val errorEvent = SingleLiveEvent<Throwable>() protected val errorEvent = SingleLiveEvent<Throwable>()
val onError: LiveData<Throwable> val onError: LiveData<Throwable>
@@ -46,4 +54,4 @@ abstract class BaseViewModel : ViewModel() {
errorEvent.postCall(throwable) errorEvent.postCall(throwable)
} }
} }
} }

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.base.ui package org.koitharu.kotatsu.base.ui
import android.app.Service
import android.content.Intent import android.content.Intent
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -20,7 +19,7 @@ abstract class CoroutineIntentService : BaseService() {
final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
launchCoroutine(intent, startId) launchCoroutine(intent, startId)
return Service.START_REDELIVER_INTENT return START_REDELIVER_INTENT
} }
private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch(errorHandler(startId)) { private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch(errorHandler(startId)) {

View File

@@ -6,8 +6,6 @@ import androidx.recyclerview.widget.RecyclerView
abstract class BoundsScrollListener(private val offsetTop: Int, private val offsetBottom: Int) : abstract class BoundsScrollListener(private val offsetTop: Int, private val offsetBottom: Int) :
RecyclerView.OnScrollListener() { RecyclerView.OnScrollListener() {
constructor(offset: Int = 0) : this(offset, offset)
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy) super.onScrolled(recyclerView, dx, dy)
val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return
@@ -28,4 +26,4 @@ abstract class BoundsScrollListener(private val offsetTop: Int, private val offs
abstract fun onScrolledToStart(recyclerView: RecyclerView) abstract fun onScrolledToStart(recyclerView: RecyclerView)
abstract fun onScrolledToEnd(recyclerView: RecyclerView) abstract fun onScrolledToEnd(recyclerView: RecyclerView)
} }

View File

@@ -0,0 +1,64 @@
package org.koitharu.kotatsu.base.ui.list
import android.os.Bundle
import android.os.Parcelable
import android.util.SparseArray
import androidx.core.os.BundleCompat
import androidx.core.view.doOnNextLayout
import androidx.recyclerview.widget.RecyclerView
import java.util.Collections
import java.util.WeakHashMap
class NestedScrollStateHandle(
savedInstanceState: Bundle?,
private val key: String,
) {
private val storage: SparseArray<Parcelable?> = savedInstanceState?.let {
BundleCompat.getSparseParcelableArray(it, key, Parcelable::class.java)
} ?: SparseArray<Parcelable?>()
private val controllers = Collections.newSetFromMap<Controller>(WeakHashMap())
fun attach(recycler: RecyclerView) = Controller(recycler).also(controllers::add)
fun onSaveInstanceState(outState: Bundle) {
controllers.forEach {
it.saveState()
}
outState.putSparseParcelableArray(key, storage)
}
inner class Controller(
private val recycler: RecyclerView
) {
private var lastPosition: Int = -1
fun onBind(position: Int) {
if (position != lastPosition) {
saveState()
lastPosition = position
storage[position]?.let {
restoreState(it)
}
}
}
fun onRecycled() {
saveState()
lastPosition = -1
}
fun saveState() {
if (lastPosition != -1) {
storage[lastPosition] = recycler.layoutManager?.onSaveInstanceState()
}
}
private fun restoreState(state: Parcelable) {
recycler.doOnNextLayout {
recycler.layoutManager?.onRestoreInstanceState(state)
}
}
}
}

View File

@@ -0,0 +1,6 @@
package org.koitharu.kotatsu.base.ui.list
interface OnTipCloseListener<T> {
fun onCloseTip(tip: T)
}

View File

@@ -1,10 +1,11 @@
package org.koitharu.kotatsu.base.ui.util package org.koitharu.kotatsu.base.ui.util
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
class ActionModeDelegate { class ActionModeDelegate : OnBackPressedCallback(false) {
private var activeActionMode: ActionMode? = null private var activeActionMode: ActionMode? = null
private var listeners: MutableList<ActionModeListener>? = null private var listeners: MutableList<ActionModeListener>? = null
@@ -12,13 +13,19 @@ class ActionModeDelegate {
val isActionModeStarted: Boolean val isActionModeStarted: Boolean
get() = activeActionMode != null get() = activeActionMode != null
override fun handleOnBackPressed() {
activeActionMode?.finish()
}
fun onSupportActionModeStarted(mode: ActionMode) { fun onSupportActionModeStarted(mode: ActionMode) {
activeActionMode = mode activeActionMode = mode
isEnabled = true
listeners?.forEach { it.onActionModeStarted(mode) } listeners?.forEach { it.onActionModeStarted(mode) }
} }
fun onSupportActionModeFinished(mode: ActionMode) { fun onSupportActionModeFinished(mode: ActionMode) {
activeActionMode = null activeActionMode = null
isEnabled = false
listeners?.forEach { it.onActionModeFinished(mode) } listeners?.forEach { it.onActionModeFinished(mode) }
} }
@@ -47,4 +54,4 @@ class ActionModeDelegate {
removeListener(listener) removeListener(listener)
} }
} }
} }

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.base.ui.util
import android.app.Activity import android.app.Activity
import android.os.Bundle import android.os.Bundle
import androidx.core.app.ActivityCompat
import org.koitharu.kotatsu.base.ui.DefaultActivityLifecycleCallbacks import org.koitharu.kotatsu.base.ui.DefaultActivityLifecycleCallbacks
import java.util.WeakHashMap import java.util.WeakHashMap
import javax.inject.Inject import javax.inject.Inject
@@ -22,6 +23,6 @@ class ActivityRecreationHandle @Inject constructor() : DefaultActivityLifecycleC
fun recreateAll() { fun recreateAll() {
val snapshot = activities.keys.toList() val snapshot = activities.keys.toList()
snapshot.forEach { it.recreate() } snapshot.forEach { ActivityCompat.recreate(it) }
} }
} }

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.base.ui.util
import dagger.hilt.EntryPoint import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@EntryPoint @EntryPoint
@@ -11,8 +10,3 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
interface BaseActivityEntryPoint { interface BaseActivityEntryPoint {
val settings: AppSettings val settings: AppSettings
} }
// Hilt cannot inject into parametrized classes
fun BaseActivityEntryPoint.inject(activity: BaseActivity<*>) {
activity.settings = settings
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.base.ui.util
import android.text.Editable
import android.text.TextWatcher
interface DefaultTextWatcher : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) = Unit
}

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.base.ui.util
import android.view.View
import androidx.lifecycle.Observer
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.utils.ext.findActivity
class ReversibleActionObserver(
private val snackbarHost: View,
) : Observer<ReversibleAction?> {
override fun onChanged(value: ReversibleAction?) {
if (value == null) {
return
}
val handle = value.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
val snackbar = Snackbar.make(snackbarHost, value.stringResId, length)
if (handle != null) {
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
}
(snackbarHost.context.findActivity() as? BottomNavOwner)?.let {
snackbar.anchorView = it.bottomNav
}
snackbar.show()
}
}

View File

@@ -10,8 +10,10 @@ class WindowInsetsDelegate(
private val listener: WindowInsetsListener, private val listener: WindowInsetsListener,
) : OnApplyWindowInsetsListener, View.OnLayoutChangeListener { ) : OnApplyWindowInsetsListener, View.OnLayoutChangeListener {
@JvmField
var handleImeInsets: Boolean = false var handleImeInsets: Boolean = false
@JvmField
var interceptingWindowInsetsListener: OnApplyWindowInsetsListener? = null var interceptingWindowInsetsListener: OnApplyWindowInsetsListener? = null
private var lastInsets: Insets? = null private var lastInsets: Insets? = null
@@ -63,4 +65,4 @@ class WindowInsetsDelegate(
fun onWindowInsetsChanged(insets: Insets) fun onWindowInsetsChanged(insets: Insets)
} }
} }

View File

@@ -1,17 +1,20 @@
package org.koitharu.kotatsu.base.ui.widgets package org.koitharu.kotatsu.base.ui.widgets
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.res.ColorStateList
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View.OnClickListener import android.view.View.OnClickListener
import androidx.annotation.DrawableRes import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import androidx.core.content.res.getColorStateListOrThrow
import androidx.core.view.children import androidx.core.view.children
import com.google.android.material.R as materialR
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipDrawable
import com.google.android.material.chip.ChipGroup import com.google.android.material.chip.ChipGroup
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.castOrNull import org.koitharu.kotatsu.utils.ext.castOrNull
import org.koitharu.kotatsu.utils.ext.getThemeColorStateList import com.google.android.material.R as materialR
class ChipsView @JvmOverloads constructor( class ChipsView @JvmOverloads constructor(
context: Context, context: Context,
@@ -27,6 +30,9 @@ class ChipsView @JvmOverloads constructor(
private val chipOnCloseListener = OnClickListener { private val chipOnCloseListener = OnClickListener {
onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag) onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag)
} }
private val defaultChipStrokeColor: ColorStateList
private val defaultChipTextColor: ColorStateList
private val defaultChipIconTint: ColorStateList
var onChipClickListener: OnChipClickListener? = null var onChipClickListener: OnChipClickListener? = null
set(value) { set(value) {
field = value field = value
@@ -40,6 +46,15 @@ class ChipsView @JvmOverloads constructor(
children.forEach { (it as? Chip)?.isCloseIconVisible = isCloseIconVisible } children.forEach { (it as? Chip)?.isCloseIconVisible = isCloseIconVisible }
} }
init {
@SuppressLint("CustomViewStyleable")
val a = context.obtainStyledAttributes(null, materialR.styleable.Chip, 0, R.style.Widget_Kotatsu_Chip)
defaultChipStrokeColor = a.getColorStateListOrThrow(materialR.styleable.Chip_chipStrokeColor)
defaultChipTextColor = a.getColorStateListOrThrow(materialR.styleable.Chip_android_textColor)
defaultChipIconTint = a.getColorStateListOrThrow(materialR.styleable.Chip_chipIconTint)
a.recycle()
}
override fun requestLayout() { override fun requestLayout() {
if (isLayoutSuppressedCompat) { if (isLayoutSuppressedCompat) {
isLayoutCalledOnSuppressed = true isLayoutCalledOnSuppressed = true
@@ -75,12 +90,15 @@ class ChipsView @JvmOverloads constructor(
private fun bindChip(chip: Chip, model: ChipModel) { private fun bindChip(chip: Chip, model: ChipModel) {
chip.text = model.title chip.text = model.title
if (model.icon == 0) { val tint = if (model.tint == 0) {
chip.isChipIconVisible = false null
} else { } else {
chip.isChipIconVisible = true ContextCompat.getColorStateList(context, model.tint)
chip.setChipIconResource(model.icon)
} }
chip.chipIconTint = tint ?: defaultChipIconTint
chip.checkedIconTint = tint ?: defaultChipIconTint
chip.chipStrokeColor = tint ?: defaultChipStrokeColor
chip.setTextColor(tint ?: defaultChipTextColor)
chip.isClickable = onChipClickListener != null || model.isCheckable chip.isClickable = onChipClickListener != null || model.isCheckable
chip.isCheckable = model.isCheckable chip.isCheckable = model.isCheckable
chip.isChecked = model.isChecked chip.isChecked = model.isChecked
@@ -92,8 +110,9 @@ class ChipsView @JvmOverloads constructor(
val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip) val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip)
chip.setChipDrawable(drawable) chip.setChipDrawable(drawable)
chip.isCheckedIconVisible = true chip.isCheckedIconVisible = true
chip.isChipIconVisible = false
chip.setCheckedIconResource(R.drawable.ic_check) chip.setCheckedIconResource(R.drawable.ic_check)
chip.checkedIconTint = context.getThemeColorStateList(materialR.attr.colorControlNormal) chip.checkedIconTint = defaultChipIconTint
chip.isCloseIconVisible = onChipCloseClickListener != null chip.isCloseIconVisible = onChipCloseClickListener != null
chip.setOnCloseIconClickListener(chipOnCloseListener) chip.setOnCloseIconClickListener(chipOnCloseListener)
chip.setEnsureMinTouchTargetSize(false) chip.setEnsureMinTouchTargetSize(false)
@@ -113,7 +132,7 @@ class ChipsView @JvmOverloads constructor(
} }
class ChipModel( class ChipModel(
@DrawableRes val icon: Int, @ColorRes val tint: Int,
val title: CharSequence, val title: CharSequence,
val isCheckable: Boolean, val isCheckable: Boolean,
val isChecked: Boolean, val isChecked: Boolean,
@@ -126,7 +145,7 @@ class ChipsView @JvmOverloads constructor(
other as ChipModel other as ChipModel
if (icon != other.icon) return false if (tint != other.tint) return false
if (title != other.title) return false if (title != other.title) return false
if (isCheckable != other.isCheckable) return false if (isCheckable != other.isCheckable) return false
if (isChecked != other.isChecked) return false if (isChecked != other.isChecked) return false
@@ -136,7 +155,7 @@ class ChipsView @JvmOverloads constructor(
} }
override fun hashCode(): Int { override fun hashCode(): Int {
var result = icon var result = tint.hashCode()
result = 31 * result + title.hashCode() result = 31 * result + title.hashCode()
result = 31 * result + isCheckable.hashCode() result = 31 * result + isCheckable.hashCode()
result = 31 * result + isChecked.hashCode() result = 31 * result + isChecked.hashCode()

View File

@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.base.ui.widgets package org.koitharu.kotatsu.base.ui.widgets
import android.animation.Animator
import android.animation.ValueAnimator
import android.content.Context import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Outline import android.graphics.Outline
@@ -7,48 +9,34 @@ import android.graphics.Paint
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import android.view.ViewOutlineProvider import android.view.ViewOutlineProvider
import android.view.animation.DecelerateInterpolator
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.FloatRange import androidx.annotation.FloatRange
import androidx.core.graphics.ColorUtils
import com.google.android.material.R as materialR
import kotlin.random.Random
import org.koitharu.kotatsu.parsers.util.replaceWith import org.koitharu.kotatsu.parsers.util.replaceWith
import org.koitharu.kotatsu.utils.ext.getAnimationDuration
import org.koitharu.kotatsu.utils.ext.getThemeColor import org.koitharu.kotatsu.utils.ext.getThemeColor
import org.koitharu.kotatsu.utils.ext.isAnimationsEnabled
import org.koitharu.kotatsu.utils.ext.resolveDp import org.koitharu.kotatsu.utils.ext.resolveDp
import com.google.android.material.R as materialR
class SegmentedBarView @JvmOverloads constructor( class SegmentedBarView @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleAttr: Int = 0, defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) { ) : View(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener, Animator.AnimatorListener {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val segmentsData = ArrayList<Segment>() private val segmentsData = ArrayList<Segment>()
private val segmentsSizes = ArrayList<Float>() private val segmentsSizes = ArrayList<Float>()
private val outlineColor = context.getThemeColor(materialR.attr.colorOutline) private val outlineColor = context.getThemeColor(materialR.attr.colorOutline)
private var cornerSize = 0f private var cornerSize = 0f
private var scaleFactor = 1f
var segments: List<Segment> private var scaleAnimator: ValueAnimator? = null
get() = segmentsData
set(value) {
segmentsData.replaceWith(value)
updateSizes()
invalidate()
}
init { init {
paint.strokeWidth = context.resources.resolveDp(1f) paint.strokeWidth = context.resources.resolveDp(1f)
outlineProvider = OutlineProvider() outlineProvider = OutlineProvider()
clipToOutline = true clipToOutline = true
if (isInEditMode) {
segments = List(Random.nextInt(3, 5)) {
Segment(
percent = Random.nextFloat(),
color = ColorUtils.HSLToColor(floatArrayOf(Random.nextInt(0, 360).toFloat(), 0.5f, 0.5f)),
)
}
}
} }
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
@@ -79,12 +67,56 @@ class SegmentedBarView @JvmOverloads constructor(
canvas.drawRoundRect(0f, 0f, w, height.toFloat(), cornerSize, cornerSize, paint) canvas.drawRoundRect(0f, 0f, w, height.toFloat(), cornerSize, cornerSize, paint)
} }
override fun onAnimationStart(animation: Animator) = Unit
override fun onAnimationEnd(animation: Animator) {
if (scaleAnimator === animation) {
scaleAnimator = null
}
}
override fun onAnimationUpdate(animation: ValueAnimator) {
scaleFactor = animation.animatedValue as Float
updateSizes()
invalidate()
}
override fun onAnimationCancel(animation: Animator) = Unit
override fun onAnimationRepeat(animation: Animator) = Unit
fun animateSegments(value: List<Segment>) {
scaleAnimator?.cancel()
segmentsData.replaceWith(value)
if (!context.isAnimationsEnabled) {
scaleAnimator = null
scaleFactor = 1f
updateSizes()
invalidate()
return
}
scaleFactor = 0f
updateSizes()
invalidate()
val animator = ValueAnimator.ofFloat(0f, 1f)
animator.duration = context.getAnimationDuration(android.R.integer.config_longAnimTime)
animator.interpolator = DecelerateInterpolator()
animator.addUpdateListener(this@SegmentedBarView)
animator.addListener(this@SegmentedBarView)
scaleAnimator = animator
animator.start()
}
private fun updateSizes() { private fun updateSizes() {
segmentsSizes.clear() segmentsSizes.clear()
segmentsSizes.ensureCapacity(segmentsData.size + 1) segmentsSizes.ensureCapacity(segmentsData.size + 1)
var w = width.toFloat() var w = width.toFloat()
for (segment in segmentsData) { val maxScale = (scaleFactor * (segmentsData.size - 1)).coerceAtLeast(1f)
val segmentWidth = (w * segment.percent).coerceAtLeast(cornerSize) for ((index, segment) in segmentsData.withIndex()) {
val scale = (scaleFactor * (index + 1) / maxScale).coerceAtMost(1f)
val segmentWidth = (w * segment.percent).coerceAtLeast(
if (index == 0) height.toFloat() else cornerSize,
) * scale
segmentsSizes.add(segmentWidth) segmentsSizes.add(segmentWidth)
w -= segmentWidth w -= segmentWidth
} }

View File

@@ -0,0 +1,104 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.ColorStateList
import android.content.res.TypedArray
import android.graphics.Color
import android.graphics.drawable.InsetDrawable
import android.graphics.drawable.RippleDrawable
import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.shapes.RoundRectShape
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.LinearLayout
import androidx.annotation.AttrRes
import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes
import androidx.core.view.updateLayoutParams
import androidx.core.widget.ImageViewCompat
import androidx.core.widget.TextViewCompat
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.databinding.ViewTwoLinesItemBinding
import org.koitharu.kotatsu.utils.ext.resolveDp
@SuppressLint("RestrictedApi")
class TwoLinesItemView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
) : LinearLayout(context, attrs, defStyleAttr) {
private val binding = ViewTwoLinesItemBinding.inflate(LayoutInflater.from(context), this)
init {
var textColors: ColorStateList? = null
context.withStyledAttributes(
set = attrs,
attrs = R.styleable.TwoLinesItemView,
defStyleAttr = defStyleAttr,
defStyleRes = R.style.Widget_Kotatsu_TwoLinesItemView,
) {
val itemRippleColor = getRippleColor(context)
val shape = createShapeDrawable(this)
val roundCorners = FloatArray(8) { resources.resolveDp(16f) }
background = RippleDrawable(
RippleUtils.sanitizeRippleDrawableColor(itemRippleColor),
shape,
ShapeDrawable(RoundRectShape(roundCorners, null, null)),
)
val drawablePadding = getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_drawablePadding, 0)
binding.layoutText.updateLayoutParams<MarginLayoutParams> { marginStart = drawablePadding }
setIconResource(getResourceId(R.styleable.TwoLinesItemView_icon, 0))
binding.title.text = getText(R.styleable.TwoLinesItemView_title)
binding.subtitle.text = getText(R.styleable.TwoLinesItemView_subtitle)
textColors = getColorStateList(R.styleable.TwoLinesItemView_android_textColor)
val textAppearanceFallback = androidx.appcompat.R.style.TextAppearance_AppCompat
TextViewCompat.setTextAppearance(
binding.title,
getResourceId(R.styleable.TwoLinesItemView_titleTextAppearance, textAppearanceFallback),
)
TextViewCompat.setTextAppearance(
binding.subtitle,
getResourceId(R.styleable.TwoLinesItemView_subtitleTextAppearance, textAppearanceFallback),
)
}
if (textColors == null) {
textColors = binding.title.textColors
}
binding.title.setTextColor(textColors)
binding.subtitle.setTextColor(textColors)
ImageViewCompat.setImageTintList(binding.icon, textColors)
}
fun setIconResource(@DrawableRes resId: Int) {
val icon = if (resId != 0) ContextCompat.getDrawable(context, resId) else null
binding.icon.setImageDrawable(icon)
}
private fun createShapeDrawable(ta: TypedArray): InsetDrawable {
val shapeAppearance = ShapeAppearanceModel.builder(
context,
ta.getResourceId(R.styleable.TwoLinesItemView_shapeAppearance, 0),
ta.getResourceId(R.styleable.TwoLinesItemView_shapeAppearanceOverlay, 0),
).build()
val shapeDrawable = MaterialShapeDrawable(shapeAppearance)
shapeDrawable.fillColor = ta.getColorStateList(R.styleable.TwoLinesItemView_backgroundFillColor)
return InsetDrawable(
shapeDrawable,
ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetLeft, 0),
ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetTop, 0),
ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetRight, 0),
ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetBottom, 0),
)
}
private fun getRippleColor(context: Context): ColorStateList {
return ContextCompat.getColorStateList(context, R.color.selector_overlay)
?: ColorStateList.valueOf(Color.TRANSPARENT)
}
}

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.bookmarks.domain
import android.database.SQLException import android.database.SQLException
import androidx.room.withTransaction import androidx.room.withTransaction
import javax.inject.Inject import dagger.Reusable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.base.domain.ReversibleHandle import org.koitharu.kotatsu.base.domain.ReversibleHandle
@@ -17,7 +17,9 @@ import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.mapItems import org.koitharu.kotatsu.utils.ext.mapItems
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import javax.inject.Inject
@Reusable
class BookmarksRepository @Inject constructor( class BookmarksRepository @Inject constructor(
private val db: MangaDatabase, private val db: MangaDatabase,
) { ) {

View File

@@ -1,7 +1,12 @@
package org.koitharu.kotatsu.bookmarks.ui package org.koitharu.kotatsu.bookmarks.ui
import android.os.Bundle import android.os.Bundle
import android.view.* import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
@@ -10,7 +15,6 @@ import androidx.fragment.app.viewModels
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.reverseAsync import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
@@ -24,6 +28,7 @@ import org.koitharu.kotatsu.bookmarks.data.ids
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksGroupAdapter import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksGroupAdapter
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
@@ -32,9 +37,9 @@ import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.invalidateNestedItemDecorations import org.koitharu.kotatsu.utils.ext.invalidateNestedItemDecorations
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class BookmarksFragment : class BookmarksFragment :
@@ -76,7 +81,7 @@ class BookmarksFragment :
binding.recyclerView.addItemDecoration(spacingDecoration) binding.recyclerView.addItemDecoration(spacingDecoration)
viewModel.content.observe(viewLifecycleOwner, ::onListChanged) viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone) viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
} }
@@ -90,6 +95,7 @@ class BookmarksFragment :
if (selectionController?.onItemClick(item.manga, item.pageId) != true) { if (selectionController?.onItemClick(item.manga, item.pageId) != true) {
val intent = ReaderActivity.newIntent(view.context, item) val intent = ReaderActivity.newIntent(view.context, item)
startActivity(intent, scaleUpActivityOptionsOf(view).toBundle()) startActivity(intent, scaleUpActivityOptionsOf(view).toBundle())
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
} }
} }
@@ -132,6 +138,7 @@ class BookmarksFragment :
mode.finish() mode.finish()
true true
} }
else -> false else -> false
} }
} }
@@ -154,14 +161,6 @@ class BookmarksFragment :
adapter?.items = list adapter?.items = list
} }
private fun onError(e: Throwable) {
Snackbar.make(
binding.recyclerView,
e.getDisplayMessage(resources),
Snackbar.LENGTH_SHORT,
).show()
}
private fun onActionDone(action: ReversibleAction) { private fun onActionDone(action: ReversibleAction) {
val handle = action.handle val handle = action.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.bookmarks.ui
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -18,7 +17,8 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.asFlowLiveData
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class BookmarksViewModel @Inject constructor( class BookmarksViewModel @Inject constructor(
@@ -43,7 +43,7 @@ class BookmarksViewModel @Inject constructor(
} }
} }
.catch { e -> emit(listOf(e.toErrorState(canRetry = false))) } .catch { e -> emit(listOf(e.toErrorState(canRetry = false))) }
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) .asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
fun removeBookmarks(ids: Map<Manga, Set<Long>>) { fun removeBookmarks(ids: Map<Manga, Set<Long>>) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {

View File

@@ -8,9 +8,12 @@ import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
import org.koitharu.kotatsu.utils.ext.decodeRegion
import org.koitharu.kotatsu.utils.ext.disposeImageRequest import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.source
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
fun bookmarkListAD( fun bookmarkListAD(
coil: ImageLoader, coil: ImageLoader,
@@ -25,12 +28,14 @@ fun bookmarkListAD(
binding.root.setOnLongClickListener(listener) binding.root.setOnLongClickListener(listener)
bind { bind {
binding.imageViewThumb.newImageRequest(item.imageUrl, item.manga.source)?.run { binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageUrl)?.run {
size(CoverSizeResolver(binding.imageViewThumb))
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)
allowRgb565(true) allowRgb565(true)
lifecycle(lifecycleOwner) decodeRegion(item.scroll)
source(item.manga.source)
enqueueWith(coil) enqueueWith(coil)
} }
} }

View File

@@ -18,6 +18,8 @@ import org.koitharu.kotatsu.utils.ext.clearItemDecorations
import org.koitharu.kotatsu.utils.ext.disposeImageRequest import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.source
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
fun bookmarksGroupAD( fun bookmarksGroupAD(
coil: ImageLoader, coil: ImageLoader,
@@ -48,12 +50,13 @@ fun bookmarksGroupAD(
binding.recyclerView.addItemDecoration(spacingDecoration) binding.recyclerView.addItemDecoration(spacingDecoration)
selectionController.attachToRecyclerView(item.manga, binding.recyclerView) selectionController.attachToRecyclerView(item.manga, binding.recyclerView)
} }
binding.imageViewCover.newImageRequest(item.manga.coverUrl, item.manga.source)?.run { binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)
allowRgb565(true) allowRgb565(true)
lifecycle(lifecycleOwner) size(CoverSizeResolver(binding.imageViewCover))
source(item.manga.source)
enqueueWith(coil) enqueueWith(coil)
} }
binding.textViewTitle.text = item.manga.title binding.textViewTitle.text = item.manga.title

View File

@@ -5,14 +5,18 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlin.jvm.internal.Intrinsics
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
import org.koitharu.kotatsu.list.ui.adapter.* import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import kotlin.jvm.internal.Intrinsics
class BookmarksGroupAdapter( class BookmarksGroupAdapter(
coil: ImageLoader, coil: ImageLoader,
@@ -38,7 +42,7 @@ class BookmarksGroupAdapter(
) )
.addDelegate(loadingStateAD()) .addDelegate(loadingStateAD())
.addDelegate(loadingFooterAD()) .addDelegate(loadingFooterAD())
.addDelegate(emptyStateListAD(coil, listener)) .addDelegate(emptyStateListAD(coil, lifecycleOwner, listener))
.addDelegate(errorStateListAD(listener)) .addDelegate(errorStateListAD(listener))
} }
@@ -49,6 +53,7 @@ class BookmarksGroupAdapter(
oldItem is BookmarksGroup && newItem is BookmarksGroup -> { oldItem is BookmarksGroup && newItem is BookmarksGroup -> {
oldItem.manga.id == newItem.manga.id oldItem.manga.id == newItem.manga.id
} }
else -> oldItem.javaClass == newItem.javaClass else -> oldItem.javaClass == newItem.javaClass
} }
} }

View File

@@ -2,14 +2,13 @@ package org.koitharu.kotatsu.browser
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
import android.webkit.WebView import android.webkit.WebView
import android.widget.ProgressBar
import androidx.core.view.isVisible import androidx.core.view.isVisible
import org.koitharu.kotatsu.utils.ext.setProgressCompat import com.google.android.material.progressindicator.BaseProgressIndicator
private const val PROGRESS_MAX = 100 private const val PROGRESS_MAX = 100
class ProgressChromeClient( class ProgressChromeClient(
private val progressIndicator: ProgressBar, private val progressIndicator: BaseProgressIndicator<*>,
) : WebChromeClient() { ) : WebChromeClient() {
init { init {
@@ -28,4 +27,4 @@ class ProgressChromeClient(
progressIndicator.isIndeterminate = true progressIndicator.isIndeterminate = true
} }
} }
} }

View File

@@ -20,14 +20,13 @@ import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding
import org.koitharu.kotatsu.utils.ext.stringArgument
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), CloudFlareCallback { class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), CloudFlareCallback {
private val url by stringArgument(ARG_URL) private lateinit var url: String
private val pendingResult = Bundle(1) private val pendingResult = Bundle(1)
@Inject @Inject
@@ -35,6 +34,11 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
private var onBackPressedCallback: WebViewBackPressedCallback? = null private var onBackPressedCallback: WebViewBackPressedCallback? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
url = requireArguments().getString(ARG_URL).orEmpty()
}
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@@ -50,12 +54,12 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
databaseEnabled = true databaseEnabled = true
userAgentString = arguments?.getString(ARG_UA) ?: CommonHeadersInterceptor.userAgentChrome userAgentString = arguments?.getString(ARG_UA) ?: CommonHeadersInterceptor.userAgentChrome
} }
binding.webView.webViewClient = CloudFlareClient(cookieJar, this, url.orEmpty()) binding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
CookieManager.getInstance().setAcceptThirdPartyCookies(binding.webView, true) CookieManager.getInstance().setAcceptThirdPartyCookies(binding.webView, true)
if (url.isNullOrEmpty()) { if (url.isEmpty()) {
dismissAllowingStateLoss() dismissAllowingStateLoss()
} else { } else {
binding.webView.loadUrl(url.orEmpty()) binding.webView.loadUrl(url)
} }
} }

View File

@@ -86,6 +86,7 @@ interface AppModule {
fun provideOkHttpClient( fun provideOkHttpClient(
localStorageManager: LocalStorageManager, localStorageManager: LocalStorageManager,
commonHeadersInterceptor: CommonHeadersInterceptor, commonHeadersInterceptor: CommonHeadersInterceptor,
mirrorSwitchInterceptor: MirrorSwitchInterceptor,
cookieJar: CookieJar, cookieJar: CookieJar,
settings: AppSettings, settings: AppSettings,
): OkHttpClient { ): OkHttpClient {
@@ -96,10 +97,14 @@ interface AppModule {
writeTimeout(20, TimeUnit.SECONDS) writeTimeout(20, TimeUnit.SECONDS)
cookieJar(cookieJar) cookieJar(cookieJar)
dns(DoHManager(cache, settings)) dns(DoHManager(cache, settings))
if (settings.isSSLBypassEnabled) {
bypassSSLErrors()
}
cache(cache) cache(cache)
addInterceptor(GZipInterceptor()) addInterceptor(GZipInterceptor())
addInterceptor(commonHeadersInterceptor) addInterceptor(commonHeadersInterceptor)
addInterceptor(CloudFlareInterceptor()) addInterceptor(CloudFlareInterceptor())
addInterceptor(mirrorSwitchInterceptor)
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
addInterceptor(CurlLoggingInterceptor()) addInterceptor(CurlLoggingInterceptor())
} }
@@ -164,7 +169,6 @@ interface AppModule {
} }
@Provides @Provides
@Singleton
@ElementsIntoSet @ElementsIntoSet
fun provideDatabaseObservers( fun provideDatabaseObservers(
widgetUpdater: WidgetUpdater, widgetUpdater: WidgetUpdater,
@@ -179,7 +183,6 @@ interface AppModule {
) )
@Provides @Provides
@Singleton
@ElementsIntoSet @ElementsIntoSet
fun provideActivityLifecycleCallbacks( fun provideActivityLifecycleCallbacks(
appProtectHelper: AppProtectHelper, appProtectHelper: AppProtectHelper,

View File

@@ -0,0 +1,66 @@
package org.koitharu.kotatsu.core.exceptions.resolve
import android.content.DialogInterface
import android.view.View
import androidx.core.util.Consumer
import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.ErrorDetailsDialog
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DialogErrorObserver(
host: View,
fragment: Fragment?,
resolver: ExceptionResolver?,
private val onResolved: Consumer<Boolean>?,
) : ErrorObserver(host, fragment, resolver, onResolved) {
constructor(
host: View,
fragment: Fragment?,
) : this(host, fragment, null, null)
override fun onChanged(value: Throwable?) {
if (value == null) {
return
}
val listener = DialogListener(value)
val dialogBuilder = MaterialAlertDialogBuilder(activity ?: host.context)
.setMessage(value.getDisplayMessage(host.context.resources))
.setNegativeButton(R.string.close, listener)
.setOnCancelListener(listener)
if (canResolve(value)) {
dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener)
} else if (value is ParseException) {
val fm = fragmentManager
if (fm != null) {
dialogBuilder.setPositiveButton(R.string.details) { _, _ ->
ErrorDetailsDialog.show(fm, value, value.url)
}
}
}
val dialog = dialogBuilder.create()
if (activity != null) {
dialog.setOwnerActivity(activity)
}
dialog.show()
}
private inner class DialogListener(
private val error: Throwable,
) : DialogInterface.OnClickListener, DialogInterface.OnCancelListener {
override fun onClick(dialog: DialogInterface?, which: Int) {
when (which) {
DialogInterface.BUTTON_NEGATIVE -> onResolved?.accept(false)
DialogInterface.BUTTON_POSITIVE -> resolve(error)
}
}
override fun onCancel(dialog: DialogInterface?) {
onResolved?.accept(false)
}
}
}

View File

@@ -0,0 +1,44 @@
package org.koitharu.kotatsu.core.exceptions.resolve
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.util.Consumer
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.lifecycle.coroutineScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.utils.ext.findActivity
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
abstract class ErrorObserver(
protected val host: View,
protected val fragment: Fragment?,
private val resolver: ExceptionResolver?,
private val onResolved: Consumer<Boolean>?,
) : Observer<Throwable?> {
protected val activity = host.context.findActivity()
private val lifecycleScope: LifecycleCoroutineScope
get() = checkNotNull(fragment?.viewLifecycleScope ?: (activity as? LifecycleOwner)?.lifecycle?.coroutineScope)
protected val fragmentManager: FragmentManager?
get() = fragment?.childFragmentManager ?: (activity as? AppCompatActivity)?.supportFragmentManager
protected fun canResolve(error: Throwable): Boolean {
return resolver != null && ExceptionResolver.canResolve(error)
}
protected fun resolve(error: Throwable) {
lifecycleScope.launch {
val isResolved = resolver?.resolve(error) ?: false
if (isActive) {
onResolved?.accept(isResolved)
}
}
}
}

View File

@@ -1,27 +1,23 @@
package org.koitharu.kotatsu.core.exceptions.resolve package org.koitharu.kotatsu.core.exceptions.resolve
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.collection.ArrayMap import androidx.collection.ArrayMap
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Headers import okhttp3.Headers
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.ui.ErrorDetailsDialog
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import org.koitharu.kotatsu.utils.TaggedActivityResult import org.koitharu.kotatsu.utils.TaggedActivityResult
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.isSuccess import org.koitharu.kotatsu.utils.isSuccess
import kotlin.coroutines.Continuation import kotlin.coroutines.Continuation
import kotlin.coroutines.resume import kotlin.coroutines.resume
@@ -43,11 +39,14 @@ class ExceptionResolver private constructor(
sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this) sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this)
} }
override fun onActivityResult(result: TaggedActivityResult?) { override fun onActivityResult(result: TaggedActivityResult) {
result ?: return
continuations.remove(result.tag)?.resume(result.isSuccess) continuations.remove(result.tag)?.resume(result.isSuccess)
} }
fun showDetails(e: Throwable, url: String?) {
ErrorDetailsDialog.show(getFragmentManager(), e, url)
}
suspend fun resolve(e: Throwable): Boolean = when (e) { suspend fun resolve(e: Throwable): Boolean = when (e) {
is CloudFlareProtectedException -> resolveCF(e.url, e.headers) is CloudFlareProtectedException -> resolveCF(e.url, e.headers)
is AuthRequiredException -> resolveAuthException(e.source) is AuthRequiredException -> resolveAuthException(e.source)
@@ -100,21 +99,5 @@ class ExceptionResolver private constructor(
} }
fun canResolve(e: Throwable) = getResolveStringId(e) != 0 fun canResolve(e: Throwable) = getResolveStringId(e) != 0
fun showDetails(context: Context, e: Throwable) {
val stackTrace = e.stackTraceToString()
val dialog = MaterialAlertDialogBuilder(context)
.setTitle(e.getDisplayMessage(context.resources))
.setMessage(stackTrace)
.setPositiveButton(androidx.preference.R.string.copy) { _, _ ->
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.setPrimaryClip(
ClipData.newPlainText(context.getString(R.string.error), stackTrace),
)
}
.setNegativeButton(R.string.close, null)
.create()
dialog.show()
}
} }
} }

View File

@@ -0,0 +1,47 @@
package org.koitharu.kotatsu.core.exceptions.resolve
import android.view.View
import androidx.core.util.Consumer
import androidx.fragment.app.Fragment
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.ErrorDetailsDialog
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class SnackbarErrorObserver(
host: View,
fragment: Fragment?,
resolver: ExceptionResolver?,
onResolved: Consumer<Boolean>?,
) : ErrorObserver(host, fragment, resolver, onResolved) {
constructor(
host: View,
fragment: Fragment?,
) : this(host, fragment, null, null)
override fun onChanged(value: Throwable?) {
if (value == null) {
return
}
val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
if (activity is BottomNavOwner) {
snackbar.anchorView = activity.bottomNav
}
if (canResolve(value)) {
snackbar.setAction(ExceptionResolver.getResolveStringId(value)) {
resolve(value)
}
} else if (value is ParseException) {
val fm = fragmentManager
if (fm != null) {
snackbar.setAction(R.string.details) {
ErrorDetailsDialog.show(fm, value, value.url)
}
}
}
snackbar.show()
}
}

View File

@@ -10,6 +10,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
@@ -28,6 +30,7 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE" private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE"
private const val CONTENT_TYPE_APK = "application/vnd.android.package-archive"
@Singleton @Singleton
class AppUpdateRepository @Inject constructor( class AppUpdateRepository @Inject constructor(
@@ -46,7 +49,9 @@ class AppUpdateRepository @Inject constructor(
.url("https://api.github.com/repos/KotatsuApp/Kotatsu/releases?page=1&per_page=10") .url("https://api.github.com/repos/KotatsuApp/Kotatsu/releases?page=1&per_page=10")
val jsonArray = okHttp.newCall(request.build()).await().parseJsonArray() val jsonArray = okHttp.newCall(request.build()).await().parseJsonArray()
return jsonArray.mapJSONNotNull { json -> return jsonArray.mapJSONNotNull { json ->
val asset = json.optJSONArray("assets")?.optJSONObject(0) ?: return@mapJSONNotNull null val asset = json.optJSONArray("assets")?.find { jo ->
jo.optString("content_type") == CONTENT_TYPE_APK
} ?: return@mapJSONNotNull null
AppVersion( AppVersion(
id = json.getLong("id"), id = json.getLong("id"),
url = json.getString("html_url"), url = json.getString("html_url"),
@@ -103,4 +108,15 @@ class AppUpdateRepository @Inject constructor(
}.onFailure { error -> }.onFailure { error ->
error.printStackTraceDebug() error.printStackTraceDebug()
}.getOrNull() }.getOrNull()
private inline fun JSONArray.find(predicate: (JSONObject) -> Boolean): JSONObject? {
val size = length()
for (i in 0 until size) {
val jo = getJSONObject(i)
if (predicate(jo)) {
return jo
}
}
return null
}
} }

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.core.model
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
@@ -8,6 +9,18 @@ import org.koitharu.kotatsu.utils.ext.iterator
fun Collection<Manga>.ids() = mapToSet { it.id } fun Collection<Manga>.ids() = mapToSet { it.id }
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) {
return size
}
val acc = HashMap<String?, Int>()
for (item in this) {
val branch = item.chapter.branch
acc[branch] = (acc[branch] ?: 0) + 1
}
return acc.values.max()
}
fun Manga.getPreferredBranch(history: MangaHistory?): String? { fun Manga.getPreferredBranch(history: MangaHistory?): String? {
val ch = chapters val ch = chapters
if (ch.isNullOrEmpty()) { if (ch.isNullOrEmpty()) {
@@ -31,4 +44,4 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
} }
} }
return groups.maxByOrNull { it.value.size }?.key return groups.maxByOrNull { it.value.size }?.key
} }

View File

@@ -52,7 +52,6 @@ fun Parcel.readManga() = Manga(
fun MangaPage.writeToParcel(out: Parcel) { fun MangaPage.writeToParcel(out: Parcel) {
out.writeLong(id) out.writeLong(id)
out.writeString(url) out.writeString(url)
out.writeString(referer)
out.writeString(preview) out.writeString(preview)
out.writeSerializable(source) out.writeSerializable(source)
} }
@@ -60,7 +59,6 @@ fun MangaPage.writeToParcel(out: Parcel) {
fun Parcel.readMangaPage() = MangaPage( fun Parcel.readMangaPage() = MangaPage(
id = readLong(), id = readLong(),
url = requireNotNull(readString()), url = requireNotNull(readString()),
referer = requireNotNull(readString()),
preview = readString(), preview = readString(),
source = checkNotNull(readSerializableCompat()), source = checkNotNull(readSerializableCompat()),
) )

View File

@@ -14,6 +14,6 @@ object CommonHeaders {
const val ACCEPT_ENCODING = "Accept-Encoding" const val ACCEPT_ENCODING = "Accept-Encoding"
const val AUTHORIZATION = "Authorization" const val AUTHORIZATION = "Authorization"
val CACHE_CONTROL_DISABLED: CacheControl val CACHE_CONTROL_NO_STORE: CacheControl
get() = CacheControl.Builder().noStore().build() get() = CacheControl.Builder().noStore().build()
} }

View File

@@ -0,0 +1,102 @@
package org.koitharu.kotatsu.core.network
import dagger.Lazy
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.ResponseBody.Companion.toResponseBody
import okhttp3.internal.canParseAsIpAddress
import okhttp3.internal.closeQuietly
import okhttp3.internal.publicsuffix.PublicSuffixDatabase
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaSource
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MirrorSwitchInterceptor @Inject constructor(
private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
return try {
val response = chain.proceed(request)
if (response.isFailed) {
val responseCopy = response.copy()
response.closeQuietly()
trySwitchMirror(request, chain)?.also {
responseCopy.closeQuietly()
} ?: responseCopy
} else {
response
}
} catch (e: Exception) {
trySwitchMirror(request, chain) ?: throw e
}
}
private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? {
val source = request.tag(MangaSource::class.java) ?: return null
val repository = mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository ?: return null
val mirrors = repository.getAvailableMirrors()
if (mirrors.isEmpty()) {
return null
}
return tryMirrors(repository, mirrors, chain, request)
}
private fun tryMirrors(
repository: RemoteMangaRepository,
mirrors: List<String>,
chain: Interceptor.Chain,
request: Request,
): Response? {
val url = request.url
val currentDomain = url.topPrivateDomain()
if (currentDomain !in mirrors) {
return null
}
val urlBuilder = url.newBuilder()
for (mirror in mirrors) {
if (mirror == currentDomain) {
continue
}
val newHost = hostOf(url.host, mirror) ?: continue
val newRequest = request.newBuilder()
.url(urlBuilder.host(newHost).build())
.build()
val response = chain.proceed(newRequest)
if (response.isFailed) {
response.closeQuietly()
} else {
repository.domain = mirror
return response
}
}
return null
}
private val Response.isFailed: Boolean
get() = code in 400..599
private fun hostOf(host: String, newDomain: String): String? {
if (newDomain.canParseAsIpAddress()) {
return newDomain
}
val domain = PublicSuffixDatabase.get().getEffectiveTldPlusOne(host) ?: return null
return host.removeSuffix(domain) + newDomain
}
private fun Response.copy(): Response {
return newBuilder()
.body(body?.copy())
.build()
}
private fun ResponseBody.copy(): ResponseBody {
return source().readByteArray().toResponseBody(contentType())
}
}

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.core.network
import android.annotation.SuppressLint
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager
@SuppressLint("CustomX509TrustManager")
fun OkHttpClient.Builder.bypassSSLErrors() = also { builder ->
runCatching {
val trustAllCerts = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) = Unit
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) = Unit
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
val sslContext = SSLContext.getInstance("SSL")
sslContext.init(null, arrayOf(trustAllCerts), SecureRandom())
val sslSocketFactory: SSLSocketFactory = sslContext.socketFactory
builder.sslSocketFactory(sslSocketFactory, trustAllCerts)
builder.hostnameVerifier { _, _ -> true }
}.onFailure {
it.printStackTraceDebug()
}
}

View File

@@ -3,23 +3,28 @@ package org.koitharu.kotatsu.core.os
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.ConnectivityManager.NetworkCallback import android.net.ConnectivityManager.NetworkCallback
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import org.koitharu.kotatsu.utils.MediatorStateFlow import org.koitharu.kotatsu.utils.MediatorStateFlow
import org.koitharu.kotatsu.utils.ext.isNetworkAvailable import org.koitharu.kotatsu.utils.ext.isOnline
class NetworkState( class NetworkState(
private val connectivityManager: ConnectivityManager, private val connectivityManager: ConnectivityManager,
) : MediatorStateFlow<Boolean>(connectivityManager.isNetworkAvailable) { ) : MediatorStateFlow<Boolean>(connectivityManager.isOnline()) {
private val callback = NetworkCallbackImpl() private val callback = NetworkCallbackImpl()
@Synchronized
override fun onActive() { override fun onActive() {
invalidate() invalidate()
val request = NetworkRequest.Builder().build() val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback) connectivityManager.registerNetworkCallback(request, callback)
} }
@Synchronized
override fun onInactive() { override fun onInactive() {
connectivityManager.unregisterNetworkCallback(callback) connectivityManager.unregisterNetworkCallback(callback)
} }
@@ -32,7 +37,7 @@ class NetworkState(
} }
private fun invalidate() { private fun invalidate() {
publishValue(connectivityManager.isNetworkAvailable) publishValue(connectivityManager.isOnline())
} }
private inner class NetworkCallbackImpl : NetworkCallback() { private inner class NetworkCallbackImpl : NetworkCallback() {

View File

@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.toList import org.koitharu.kotatsu.utils.ext.toList
import java.lang.ref.WeakReference
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -28,10 +29,14 @@ class MangaLoaderContextImpl @Inject constructor(
@ApplicationContext private val androidContext: Context, @ApplicationContext private val androidContext: Context,
) : MangaLoaderContext() { ) : MangaLoaderContext() {
private var webViewCached: WeakReference<WebView>? = null
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main) { override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main) {
val webView = WebView(androidContext) val webView = webViewCached?.get() ?: WebView(androidContext).also {
webView.settings.javaScriptEnabled = true it.settings.javaScriptEnabled = true
webViewCached = WeakReference(it)
}
suspendCoroutine { cont -> suspendCoroutine { cont ->
webView.evaluateJavascript(script) { result -> webView.evaluateJavascript(script) { result ->
cont.resume(result?.takeUnless { it == "null" }) cont.resume(result?.takeUnless { it == "null" })

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import androidx.annotation.AnyThread
import org.koitharu.kotatsu.core.cache.ContentCache import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
@@ -42,6 +43,7 @@ interface MangaRepository {
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java) private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
@AnyThread
fun create(source: MangaSource): MangaRepository { fun create(source: MangaSource): MangaRepository {
if (source == MangaSource.LOCAL) { if (source == MangaSource.LOCAL) {
return localMangaRepository return localMangaRepository

View File

@@ -0,0 +1,37 @@
package org.koitharu.kotatsu.core.parser
import android.content.Context
import androidx.annotation.ColorRes
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.model.MangaTag
import javax.inject.Inject
@Reusable
class MangaTagHighlighter @Inject constructor(
@ApplicationContext context: Context,
) {
private val dict by lazy {
context.resources.openRawResource(R.raw.tags_redlist).use {
val set = HashSet<String>()
it.bufferedReader().forEachLine { x ->
val line = x.trim()
if (line.isNotEmpty()) {
set.add(line)
}
}
set
}
}
@ColorRes
fun getTint(tag: MangaTag): Int {
return if (tag.title.lowercase() in dict) {
R.color.warning
} else {
0
}
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import android.util.Log
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -9,6 +10,7 @@ import kotlinx.coroutines.currentCoroutineContext
import okhttp3.Headers import okhttp3.Headers
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.cache.ContentCache import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.cache.SafeDeferred import org.koitharu.kotatsu.core.cache.SafeDeferred
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
@@ -43,8 +45,11 @@ class RemoteMangaRepository(
getConfig().defaultSortOrder = value getConfig().defaultSortOrder = value
} }
val domain: String var domain: String
get() = parser.domain get() = parser.domain
set(value) {
getConfig()[parser.configKeyDomain] = value
}
val headers: Headers? val headers: Headers?
get() = parser.headers get() = parser.headers
@@ -77,7 +82,7 @@ class RemoteMangaRepository(
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
cache.getPages(source, chapter.url)?.let { return it } cache.getPages(source, chapter.url)?.let { return it }
val pages = asyncSafe { val pages = asyncSafe {
parser.getPages(chapter) parser.getPages(chapter).distinctById()
} }
cache.putPages(source, chapter.url, pages) cache.putPages(source, chapter.url, pages)
return pages.await() return pages.await()
@@ -95,6 +100,10 @@ class RemoteMangaRepository(
parser.onCreateConfig(it) parser.onCreateConfig(it)
} }
fun getAvailableMirrors(): List<String> {
return parser.configKeyDomain.presetValues?.toList().orEmpty()
}
private fun getConfig() = parser.config as SourceSettings private fun getConfig() = parser.config as SourceSettings
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> { private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
@@ -108,4 +117,20 @@ class RemoteMangaRepository(
}, },
) )
} }
private fun List<MangaPage>.distinctById(): List<MangaPage> {
if (isEmpty()) {
return emptyList()
}
val result = ArrayList<MangaPage>(size)
val set = HashSet<Long>(size)
for (page in this) {
if (set.add(page.id)) {
result.add(page)
} else if (BuildConfig.DEBUG) {
Log.w(null, "Duplicate page: $page")
}
}
return result
}
} }

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.annotation.FloatRange
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.arraySetOf import androidx.collection.arraySetOf
import androidx.core.content.edit import androidx.core.content.edit
@@ -255,6 +256,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val dnsOverHttps: DoHProvider val dnsOverHttps: DoHProvider
get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE) get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE)
val isSSLBypassEnabled: Boolean
get() = prefs.getBoolean(KEY_SSL_BYPASS, false)
var localListOrder: SortOrder var localListOrder: SortOrder
get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST) get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST)
set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) } set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) }
@@ -262,6 +266,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isWebtoonZoomEnable: Boolean val isWebtoonZoomEnable: Boolean
get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true) get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true)
@get:FloatRange(from = 0.0, to = 1.0)
var readerAutoscrollSpeed: Float
get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f)
set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit { putFloat(KEY_READER_AUTOSCROLL_SPEED, value) }
fun isPagesPreloadEnabled(): Boolean { fun isPagesPreloadEnabled(): Boolean {
val policy = NetworkPolicy.from(prefs.getString(KEY_PAGES_PRELOAD, null), NetworkPolicy.NON_METERED) val policy = NetworkPolicy.from(prefs.getString(KEY_PAGES_PRELOAD, null), NetworkPolicy.NON_METERED)
return policy.isNetworkAllowed(connectivityManager) return policy.isNetworkAllowed(connectivityManager)
@@ -293,6 +302,18 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return list return list
} }
fun isTipEnabled(tip: String): Boolean {
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
}
fun closeTip(tip: String) {
val closedTips = prefs.getStringSet(KEY_TIPS_CLOSED, emptySet()).orEmpty()
if (tip in closedTips) {
return
}
prefs.edit { putStringSet(KEY_TIPS_CLOSED, closedTips + tip) }
}
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) { fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener) prefs.registerOnSharedPreferenceChangeListener(listener)
} }
@@ -380,6 +401,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LOGS_SHARE = "logs_share" const val KEY_LOGS_SHARE = "logs_share"
const val KEY_SOURCES_GRID = "sources_grid" const val KEY_SOURCES_GRID = "sources_grid"
const val KEY_UPDATES_UNSTABLE = "updates_unstable" const val KEY_UPDATES_UNSTABLE = "updates_unstable"
const val KEY_TIPS_CLOSED = "tips_closed"
const val KEY_SSL_BYPASS = "ssl_bypass"
const val KEY_READER_AUTOSCROLL_SPEED = "as_speed"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

View File

@@ -23,8 +23,17 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T> get(key: ConfigKey<T>): T { override fun <T> get(key: ConfigKey<T>): T {
return when (key) { return when (key) {
is ConfigKey.UserAgent -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue } is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue) is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
} as T } as T
} }
operator fun <T> set(key: ConfigKey<T>, value: T) = prefs.edit {
when (key) {
is ConfigKey.Domain -> putString(key.key, value as String?)
is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
is ConfigKey.UserAgent -> putString(key.key, value as String?)
}
}
} }

View File

@@ -1,5 +1,8 @@
package org.koitharu.kotatsu.core.ui package org.koitharu.kotatsu.core.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.text.method.LinkMovementMethod import android.text.method.LinkMovementMethod
import android.view.LayoutInflater import android.view.LayoutInflater
@@ -12,28 +15,24 @@ import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.databinding.DialogErrorDetailsBinding
import org.koitharu.kotatsu.databinding.DialogMangaErrorBinding import org.koitharu.kotatsu.utils.ext.isReportable
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.report import org.koitharu.kotatsu.utils.ext.report
import org.koitharu.kotatsu.utils.ext.requireParcelable
import org.koitharu.kotatsu.utils.ext.requireSerializable import org.koitharu.kotatsu.utils.ext.requireSerializable
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
class MangaErrorDialog : AlertDialogFragment<DialogMangaErrorBinding>() { class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
private lateinit var error: Throwable private lateinit var exception: Throwable
private lateinit var manga: Manga
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val args = requireArguments() val args = requireArguments()
manga = args.requireParcelable<ParcelableManga>(ARG_MANGA).manga exception = args.requireSerializable(ARG_ERROR)
error = args.requireSerializable(ARG_ERROR)
} }
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): DialogMangaErrorBinding { override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): DialogErrorDetailsBinding {
return DialogMangaErrorBinding.inflate(inflater, container, false) return DialogErrorDetailsBinding.inflate(inflater, container, false)
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -42,31 +41,47 @@ class MangaErrorDialog : AlertDialogFragment<DialogMangaErrorBinding>() {
movementMethod = LinkMovementMethod.getInstance() movementMethod = LinkMovementMethod.getInstance()
text = context.getString( text = context.getString(
R.string.manga_error_description_pattern, R.string.manga_error_description_pattern,
this@MangaErrorDialog.error.message?.htmlEncode().orEmpty(), exception.message?.htmlEncode().orEmpty(),
manga.publicUrl, arguments?.getString(ARG_URL),
).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY) ).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY)
} }
} }
@Suppress("NAME_SHADOWING")
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder) val builder = super.onBuildDialog(builder)
.setCancelable(true) .setCancelable(true)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.report) { _, _ -> .setTitle(R.string.error_occurred)
.setNeutralButton(androidx.preference.R.string.copy) { _, _ ->
copyToClipboard()
}
if (exception.isReportable()) {
builder.setPositiveButton(R.string.report) { _, _ ->
dismiss() dismiss()
error.report() exception.report()
}.setTitle(R.string.error_occurred) }
}
return builder
}
private fun copyToClipboard() {
val clipboardManager = context?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
?: return
clipboardManager.setPrimaryClip(
ClipData.newPlainText(getString(R.string.error), exception.stackTraceToString()),
)
} }
companion object { companion object {
private const val TAG = "MangaErrorDialog" private const val TAG = "ErrorDetailsDialog"
private const val ARG_ERROR = "error" private const val ARG_ERROR = "error"
private const val ARG_MANGA = "manga" private const val ARG_URL = "url"
fun show(fm: FragmentManager, manga: Manga, error: Throwable) = MangaErrorDialog().withArgs(2) { fun show(fm: FragmentManager, error: Throwable, url: String?) = ErrorDetailsDialog().withArgs(2) {
putParcelable(ARG_MANGA, ParcelableManga(manga, false))
putSerializable(ARG_ERROR, error) putSerializable(ARG_ERROR, error)
putString(ARG_URL, url)
}.show(fm, TAG) }.show(fm, TAG)
} }
} }

View File

@@ -13,6 +13,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.Toast import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isGone import androidx.core.view.isGone
@@ -29,10 +30,9 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar import org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.os.ShortcutsUpdater import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.ui.MangaErrorDialog
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
@@ -43,9 +43,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.utils.ViewBadge import org.koitharu.kotatsu.utils.ViewBadge
import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.isReportable
import org.koitharu.kotatsu.utils.ext.setNavigationBarTransparentCompat import org.koitharu.kotatsu.utils.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.utils.ext.textAndVisible import org.koitharu.kotatsu.utils.ext.textAndVisible
import javax.inject.Inject import javax.inject.Inject
@@ -60,17 +58,12 @@ class DetailsActivity :
override val bsHeader: BottomSheetHeaderBar? override val bsHeader: BottomSheetHeaderBar?
get() = binding.headerChapters get() = binding.headerChapters
@Inject
lateinit var viewModelFactory: DetailsViewModel.Factory
@Inject @Inject
lateinit var shortcutsUpdater: ShortcutsUpdater lateinit var shortcutsUpdater: ShortcutsUpdater
private lateinit var viewBadge: ViewBadge private lateinit var viewBadge: ViewBadge
private val viewModel: DetailsViewModel by assistedViewModels { private val viewModel: DetailsViewModel by viewModels()
viewModelFactory.create(MangaIntent(intent))
}
private lateinit var chaptersMenuProvider: ChaptersMenuProvider private lateinit var chaptersMenuProvider: ChaptersMenuProvider
private val downloadReceiver = object : BroadcastReceiver() { private val downloadReceiver = object : BroadcastReceiver() {
@@ -105,7 +98,19 @@ class DetailsActivity :
viewModel.manga.observe(this, ::onMangaUpdated) viewModel.manga.observe(this, ::onMangaUpdated)
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved) viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
viewModel.onError.observe(this, ::onError) viewModel.onError.observe(
this,
SnackbarErrorObserver(
host = binding.containerDetails,
fragment = null,
resolver = exceptionResolver,
onResolved = { isResolved ->
if (isResolved) {
viewModel.reload()
}
},
),
)
viewModel.onShowToast.observe(this) { viewModel.onShowToast.observe(this) {
makeSnackbar(getString(it), Snackbar.LENGTH_SHORT).show() makeSnackbar(getString(it), Snackbar.LENGTH_SHORT).show()
} }
@@ -191,37 +196,6 @@ class DetailsActivity :
finishAfterTransition() finishAfterTransition()
} }
private fun onError(e: Throwable) {
val manga = viewModel.manga.value
when {
ExceptionResolver.canResolve(e) -> {
resolveError(e)
}
manga == null -> {
Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
finishAfterTransition()
}
else -> {
val snackbar = makeSnackbar(
e.getDisplayMessage(resources),
if (viewModel.manga.value?.chapters == null) {
Snackbar.LENGTH_INDEFINITE
} else {
Snackbar.LENGTH_LONG
},
)
if (e.isReportable()) {
snackbar.setAction(R.string.details) {
MangaErrorDialog.show(supportFragmentManager, manga, e)
}
}
snackbar.show()
}
}
}
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
binding.root.updatePadding( binding.root.updatePadding(
left = insets.left, left = insets.left,
@@ -331,17 +305,17 @@ class DetailsActivity :
private class PrefetchObserver( private class PrefetchObserver(
private val context: Context, private val context: Context,
) : Observer<List<ChapterListItem>> { ) : Observer<List<ChapterListItem>?> {
private var isCalled = false private var isCalled = false
override fun onChanged(t: List<ChapterListItem>?) { override fun onChanged(value: List<ChapterListItem>?) {
if (t.isNullOrEmpty()) { if (value.isNullOrEmpty()) {
return return
} }
if (!isCalled) { if (!isCalled) {
isCalled = true isCalled = true
val item = t.find { it.hasFlag(ChapterListItem.FLAG_CURRENT) } ?: t.first() val item = value.find { it.hasFlag(ChapterListItem.FLAG_CURRENT) } ?: value.first()
MangaPrefetchService.prefetchPages(context, item.chapter) MangaPrefetchService.prefetchPages(context, item.chapter)
} }
} }

View File

@@ -5,6 +5,7 @@ import android.text.method.LinkMovementMethod
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
@@ -26,6 +27,8 @@ import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.core.model.countChaptersByBranch
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.model.HistoryInfo
@@ -39,7 +42,6 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.search.ui.SearchActivity
@@ -62,13 +64,15 @@ import javax.inject.Inject
class DetailsFragment : class DetailsFragment :
BaseFragment<FragmentDetailsBinding>(), BaseFragment<FragmentDetailsBinding>(),
View.OnClickListener, View.OnClickListener,
View.OnLongClickListener,
ChipsView.OnChipClickListener, ChipsView.OnChipClickListener,
OnListItemClickListener<Bookmark> { OnListItemClickListener<Bookmark> {
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
@Inject
lateinit var tagHighlighter: MangaTagHighlighter
private val viewModel by activityViewModels<DetailsViewModel>() private val viewModel by activityViewModels<DetailsViewModel>()
override fun onInflateView( override fun onInflateView(
@@ -97,6 +101,7 @@ class DetailsFragment :
ReaderActivity.newIntent(view.context, item), ReaderActivity.newIntent(view.context, item),
scaleUpActivityOptionsOf(view).toBundle(), scaleUpActivityOptionsOf(view).toBundle(),
) )
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
} }
override fun onItemLongClick(item: Bookmark, view: View): Boolean { override fun onItemLongClick(item: Bookmark, view: View): Boolean {
@@ -173,12 +178,9 @@ class DetailsFragment :
if (chapters.isNullOrEmpty()) { if (chapters.isNullOrEmpty()) {
infoLayout.textViewChapters.isVisible = false infoLayout.textViewChapters.isVisible = false
} else { } else {
val count = chapters.countChaptersByBranch()
infoLayout.textViewChapters.isVisible = true infoLayout.textViewChapters.isVisible = true
infoLayout.textViewChapters.text = resources.getQuantityString( infoLayout.textViewChapters.text = resources.getQuantityString(R.plurals.chapters, count, count)
R.plurals.chapters,
chapters.size,
chapters.size,
)
} }
} }
@@ -264,43 +266,6 @@ class DetailsFragment :
} }
} }
override fun onLongClick(v: View): Boolean {
when (v.id) {
R.id.button_read -> {
if (viewModel.historyInfo.value?.history == null) {
return false
}
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@setOnMenuItemClickListener false,
manga = viewModel.manga.value ?: return@setOnMenuItemClickListener false,
state = viewModel.chapters.value?.firstOrNull { c ->
c.chapter.branch == branch
}?.let { c ->
ReaderState(c.chapter.id, 0, 0)
},
),
)
true
}
else -> false
}
}
menu.show()
return true
}
else -> return false
}
}
override fun onChipClick(chip: Chip, data: Any?) { override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag ?: return val tag = data as? MangaTag ?: return
startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag))) startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag)))
@@ -321,7 +286,7 @@ class DetailsFragment :
manga.tags.map { tag -> manga.tags.map { tag ->
ChipsView.ChipModel( ChipsView.ChipModel(
title = tag.title, title = tag.title,
icon = 0, tint = tagHighlighter.getTint(tag),
data = tag, data = tag,
isCheckable = false, isCheckable = false,
isChecked = false, isChecked = false,
@@ -341,7 +306,7 @@ class DetailsFragment :
.size(CoverSizeResolver(binding.imageViewCover)) .size(CoverSizeResolver(binding.imageViewCover))
.data(imageUrl) .data(imageUrl)
.tag(manga.source) .tag(manga.source)
.crossfade(context) .crossfade(requireContext())
.lifecycle(viewLifecycleOwner) .lifecycle(viewLifecycleOwner)
.placeholderMemoryCacheKey(manga.coverUrl) .placeholderMemoryCacheKey(manga.coverUrl)
val previousDrawable = lastResult?.drawable val previousDrawable = lastResult?.drawable

View File

@@ -10,9 +10,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -28,12 +26,9 @@ import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.details.domain.BranchComparator import org.koitharu.kotatsu.details.domain.BranchComparator
@@ -51,33 +46,24 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import java.io.IOException import java.io.IOException
import javax.inject.Inject
class DetailsViewModel @AssistedInject constructor( @HiltViewModel
@Assisted intent: MangaIntent, class DetailsViewModel @Inject constructor(
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
favouritesRepository: FavouritesRepository, favouritesRepository: FavouritesRepository,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
trackingRepository: TrackingRepository, trackingRepository: TrackingRepository,
mangaDataRepository: MangaDataRepository,
private val bookmarksRepository: BookmarksRepository, private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>, private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
private val imageGetter: Html.ImageGetter, private val imageGetter: Html.ImageGetter,
mangaRepositoryFactory: MangaRepository.Factory, private val delegate: MangaDetailsDelegate,
) : BaseViewModel() { ) : BaseViewModel() {
private val delegate = MangaDetailsDelegate(
intent = intent,
mangaDataRepository = mangaDataRepository,
historyRepository = historyRepository,
localMangaRepository = localMangaRepository,
mangaRepositoryFactory = mangaRepositoryFactory,
)
private var loadingJob: Job private var loadingJob: Job
val onShowToast = SingleLiveEvent<Int>() val onShowToast = SingleLiveEvent<Int>()
@@ -109,18 +95,19 @@ class DetailsViewModel @AssistedInject constructor(
val historyInfo: LiveData<HistoryInfo> = combine( val historyInfo: LiveData<HistoryInfo> = combine(
delegate.manga, delegate.manga,
delegate.selectedBranch,
history, history,
historyRepository.observeShouldSkip(delegate.manga), historyRepository.observeShouldSkip(delegate.manga),
) { m, h, im -> ) { m, b, h, im ->
HistoryInfo(m, h, im) HistoryInfo(m, b, h, im)
}.asFlowLiveData( }.asFlowLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default, context = viewModelScope.coroutineContext + Dispatchers.Default,
defaultValue = HistoryInfo(null, null, false), defaultValue = HistoryInfo(null, null, null, false),
) )
val bookmarks = delegate.manga.flatMapLatest { val bookmarks = delegate.manga.flatMapLatest {
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList()) if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val description = delegate.manga val description = delegate.manga
.distinctUntilChangedBy { it?.description.orEmpty() } .distinctUntilChangedBy { it?.description.orEmpty() }
@@ -132,7 +119,7 @@ class DetailsViewModel @AssistedInject constructor(
emit(description.parseAsHtml().filterSpans()) emit(description.parseAsHtml().filterSpans())
emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans()) emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans())
} }
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, null)
val onMangaRemoved = SingleLiveEvent<Manga>() val onMangaRemoved = SingleLiveEvent<Manga>()
val isScrobblingAvailable: Boolean val isScrobblingAvailable: Boolean
@@ -154,7 +141,7 @@ class DetailsViewModel @AssistedInject constructor(
delegate.selectedBranch, delegate.selectedBranch,
) { branches, selected -> ) { branches, selected ->
branches.indexOf(selected) branches.indexOf(selected)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, -1) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, -1)
val selectedBranchName = delegate.selectedBranch val selectedBranchName = delegate.selectedBranch
.asFlowLiveData(viewModelScope.coroutineContext, null) .asFlowLiveData(viewModelScope.coroutineContext, null)
@@ -164,7 +151,7 @@ class DetailsViewModel @AssistedInject constructor(
isLoading.asFlow(), isLoading.asFlow(),
) { m, loading -> ) { m, loading ->
m != null && m.chapters.isNullOrEmpty() && !loading m != null && m.chapters.isNullOrEmpty() && !loading
}.asLiveDataDistinct(viewModelScope.coroutineContext, false) }.asFlowLiveData(viewModelScope.coroutineContext, false)
val chapters = combine( val chapters = combine(
combine( combine(
@@ -201,7 +188,7 @@ class DetailsViewModel @AssistedInject constructor(
return return
} }
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m) val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)?.manga
checkNotNull(manga) { "Cannot find saved manga for ${m.title}" } checkNotNull(manga) { "Cannot find saved manga for ${m.title}" }
val original = localMangaRepository.getRemoteManga(manga) val original = localMangaRepository.getRemoteManga(manga)
localMangaRepository.delete(manga) || throw IOException("Unable to delete file") localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
@@ -279,7 +266,7 @@ class DetailsViewModel @AssistedInject constructor(
fun markChapterAsCurrent(chapterId: Long) { fun markChapterAsCurrent(chapterId: Long) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val manga = checkNotNull(delegate.manga.value) val manga = checkNotNull(delegate.manga.value)
val chapters = checkNotNull(manga.chapters) val chapters = checkNotNull(manga.getChapters(selectedBranchValue))
val chapterIndex = chapters.indexOfFirst { it.id == chapterId } val chapterIndex = chapters.indexOfFirst { it.id == chapterId }
check(chapterIndex in chapters.indices) { "Chapter not found" } check(chapterIndex in chapters.indices) { "Chapter not found" }
val percent = chapterIndex / chapters.size.toFloat() val percent = chapterIndex / chapters.size.toFloat()
@@ -321,10 +308,4 @@ class DetailsViewModel @AssistedInject constructor(
} }
return scrobbler return scrobbler
} }
@AssistedFactory
interface Factory {
fun create(intent: MangaIntent): DetailsViewModel
}
} }

View File

@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.details.ui package org.koitharu.kotatsu.details.ui
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
@@ -17,15 +19,17 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject
class MangaDetailsDelegate( @ViewModelScoped
private val intent: MangaIntent, class MangaDetailsDelegate @Inject constructor(
savedStateHandle: SavedStateHandle,
private val mangaDataRepository: MangaDataRepository, private val mangaDataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
) { ) {
private val intent = MangaIntent(savedStateHandle)
private val mangaData = MutableStateFlow(intent.manga) private val mangaData = MutableStateFlow(intent.manga)
val selectedBranch = MutableStateFlow<String?>(null) val selectedBranch = MutableStateFlow<String?>(null)
@@ -49,7 +53,7 @@ class MangaDetailsDelegate(
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatchingCancellable null val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatchingCancellable null
mangaRepositoryFactory.create(m.source).getDetails(m) mangaRepositoryFactory.create(m.source).getDetails(m)
} else { } else {
localMangaRepository.findSavedManga(manga) localMangaRepository.findSavedManga(manga)?.manga
} }
}.onFailure { error -> }.onFailure { error ->
error.printStackTraceDebug() error.printStackTraceDebug()

View File

@@ -36,8 +36,13 @@ class HistoryInfo(
} }
} }
fun HistoryInfo(manga: Manga?, history: MangaHistory?, isIncognitoMode: Boolean): HistoryInfo { fun HistoryInfo(
val chapters = manga?.chapters manga: Manga?,
branch: String?,
history: MangaHistory?,
isIncognitoMode: Boolean
): HistoryInfo {
val chapters = manga?.getChapters(branch)
return HistoryInfo( return HistoryInfo(
totalChapters = chapters?.size ?: -1, totalChapters = chapters?.size ?: -1,
currentChapter = if (history != null && !chapters.isNullOrEmpty()) { currentChapter = if (history != null && !chapters.isNullOrEmpty()) {

View File

@@ -23,11 +23,10 @@ fun scrobblingInfoAD(
} }
bind { bind {
binding.imageViewCover.newImageRequest(item.coverUrl /* TODO */, null)?.run { binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run {
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)
lifecycle(lifecycleOwner)
enqueueWith(coil) enqueueWith(coil)
} }
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title

View File

@@ -123,8 +123,7 @@ class ScrobblingInfoBottomSheet :
binding.spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1) binding.spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1)
binding.imageViewLogo.contentDescription = getString(scrobbling.scrobbler.titleResId) binding.imageViewLogo.contentDescription = getString(scrobbling.scrobbler.titleResId)
binding.imageViewLogo.setImageResource(scrobbling.scrobbler.iconResId) binding.imageViewLogo.setImageResource(scrobbling.scrobbler.iconResId)
binding.imageViewCover.newImageRequest(scrobbling.coverUrl)?.apply { binding.imageViewCover.newImageRequest(viewLifecycleOwner, scrobbling.coverUrl)?.apply {
lifecycle(viewLifecycleOwner)
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)

View File

@@ -1,17 +1,17 @@
package org.koitharu.kotatsu.download.domain package org.koitharu.kotatsu.download.domain
import android.app.Service
import android.content.Context import android.content.Context
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Scale import coil.size.Scale
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ServiceScoped
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -30,7 +30,8 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.ui.service.PausingHandle import org.koitharu.kotatsu.download.ui.service.PausingHandle
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.CbzMangaOutput import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -41,13 +42,15 @@ import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.progress.PausingProgressJob import org.koitharu.kotatsu.utils.progress.PausingProgressJob
import java.io.File import java.io.File
import javax.inject.Inject
private const val MAX_FAILSAFE_ATTEMPTS = 2 private const val MAX_FAILSAFE_ATTEMPTS = 2
private const val DOWNLOAD_ERROR_DELAY = 500L private const val DOWNLOAD_ERROR_DELAY = 500L
private const val SLOWDOWN_DELAY = 200L private const val SLOWDOWN_DELAY = 150L
class DownloadManager @AssistedInject constructor( @ServiceScoped
@Assisted private val coroutineScope: CoroutineScope, class DownloadManager @Inject constructor(
service: Service,
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val imageLoader: ImageLoader, private val imageLoader: ImageLoader,
private val okHttp: OkHttpClient, private val okHttp: OkHttpClient,
@@ -64,6 +67,7 @@ class DownloadManager @AssistedInject constructor(
androidx.core.R.dimen.compat_notification_large_icon_max_height, androidx.core.R.dimen.compat_notification_large_icon_max_height,
) )
private val semaphore = Semaphore(settings.downloadsParallelism) private val semaphore = Semaphore(settings.downloadsParallelism)
private val coroutineScope = (service as LifecycleService).lifecycleScope
fun downloadManga( fun downloadManga(
manga: Manga, manga: Manga,
@@ -103,10 +107,10 @@ class DownloadManager @AssistedInject constructor(
withMangaLock(manga) { withMangaLock(manga) {
semaphore.withPermit { semaphore.withPermit {
outState.value = DownloadState.Preparing(startId, manga, null) outState.value = DownloadState.Preparing(startId, manga, null)
val destination = localMangaRepository.getOutputDir() val destination = localMangaRepository.getOutputDir(manga)
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) } checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
val tempFileName = "${manga.id}_$startId.tmp" val tempFileName = "${manga.id}_$startId.tmp"
var output: CbzMangaOutput? = null var output: LocalMangaOutput? = null
try { try {
if (manga.source == MangaSource.LOCAL) { if (manga.source == MangaSource.LOCAL) {
manga = localMangaRepository.getRemoteManga(manga) manga = localMangaRepository.getRemoteManga(manga)
@@ -115,9 +119,9 @@ class DownloadManager @AssistedInject constructor(
val repo = mangaRepositoryFactory.create(manga.source) val repo = mangaRepositoryFactory.create(manga.source)
outState.value = DownloadState.Preparing(startId, manga, cover) outState.value = DownloadState.Preparing(startId, manga, cover)
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = CbzMangaOutput.get(destination, data) output = LocalMangaOutput.getOrCreate(destination, data)
val coverUrl = data.largeCoverUrl ?: data.coverUrl val coverUrl = data.largeCoverUrl ?: data.coverUrl
downloadFile(coverUrl, data.publicUrl, destination, tempFileName, repo.source).let { file -> downloadFile(coverUrl, destination, tempFileName, repo.source).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
} }
val chapters = checkNotNull( val chapters = checkNotNull(
@@ -138,8 +142,8 @@ class DownloadManager @AssistedInject constructor(
for ((pageIndex, page) in pages.withIndex()) { for ((pageIndex, page) in pages.withIndex()) {
runFailsafe(outState, pausingHandle) { runFailsafe(outState, pausingHandle) {
val url = repo.getPageUrl(page) val url = repo.getPageUrl(page)
val file = cache[url] val file = cache.get(url)
?: downloadFile(url, page.referer, destination, tempFileName, repo.source) ?: downloadFile(url, destination, tempFileName, repo.source)
output.addPage( output.addPage(
chapter = chapter, chapter = chapter,
file = file, file = file,
@@ -161,11 +165,12 @@ class DownloadManager @AssistedInject constructor(
delay(SLOWDOWN_DELAY) delay(SLOWDOWN_DELAY)
} }
} }
output.flushChapter(chapter)
} }
outState.value = DownloadState.PostProcessing(startId, data, cover) outState.value = DownloadState.PostProcessing(startId, data, cover)
output.mergeWithExisting() output.mergeWithExisting()
output.finish() output.finish()
val localManga = localMangaRepository.getFromFile(output.file) val localManga = LocalMangaInput.of(output.rootFile).getManga().manga
outState.value = DownloadState.Done(startId, data, cover, localManga) outState.value = DownloadState.Done(startId, data, cover, localManga)
} catch (e: CancellationException) { } catch (e: CancellationException) {
outState.value = DownloadState.Cancelled(startId, manga, cover) outState.value = DownloadState.Cancelled(startId, manga, cover)
@@ -211,16 +216,14 @@ class DownloadManager @AssistedInject constructor(
private suspend fun downloadFile( private suspend fun downloadFile(
url: String, url: String,
referer: String,
destination: File, destination: File,
tempFileName: String, tempFileName: String,
source: MangaSource, source: MangaSource,
): File { ): File {
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
.header(CommonHeaders.REFERER, referer)
.tag(MangaSource::class.java, source) .tag(MangaSource::class.java, source)
.cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED) .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
.get() .get()
.build() .build()
val call = okHttp.newCall(request) val call = okHttp.newCall(request)
@@ -249,6 +252,7 @@ class DownloadManager @AssistedInject constructor(
imageLoader.execute( imageLoader.execute(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(manga.coverUrl) .data(manga.coverUrl)
.allowHardware(false)
.tag(manga.source) .tag(manga.source)
.size(coverWidth, coverHeight) .size(coverWidth, coverHeight)
.scale(Scale.FILL) .scale(Scale.FILL)
@@ -262,10 +266,4 @@ class DownloadManager @AssistedInject constructor(
} finally { } finally {
localMangaRepository.unlockManga(manga.id) localMangaRepository.unlockManga(manga.id)
} }
@AssistedFactory
interface Factory {
fun create(coroutineScope: CoroutineScope): DownloadManager
}
} }

View File

@@ -2,9 +2,10 @@ package org.koitharu.kotatsu.download.ui
import android.view.View import android.view.View
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@@ -17,9 +18,10 @@ import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.onFirst import org.koitharu.kotatsu.utils.ext.onFirst
import org.koitharu.kotatsu.utils.ext.source
fun downloadItemAD( fun downloadItemAD(
scope: CoroutineScope, lifecycleOwner: LifecycleOwner,
coil: ImageLoader, coil: ImageLoader,
) = adapterDelegateViewBinding<DownloadItem, DownloadItem, ItemDownloadBinding>( ) = adapterDelegateViewBinding<DownloadItem, DownloadItem, ItemDownloadBinding>(
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }, { inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) },
@@ -43,10 +45,11 @@ fun downloadItemAD(
bind { bind {
job?.cancel() job?.cancel()
job = item.progressAsFlow().onFirst { state -> job = item.progressAsFlow().onFirst { state ->
binding.imageViewCover.newImageRequest(state.manga.coverUrl, state.manga.source)?.run { binding.imageViewCover.newImageRequest(lifecycleOwner, state.manga.coverUrl)?.run {
placeholder(state.cover) placeholder(state.cover)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)
source(state.manga.source)
allowRgb565(true) allowRgb565(true)
enqueueWith(coil) enqueueWith(coil)
} }
@@ -127,7 +130,7 @@ fun downloadItemAD(
binding.buttonResume.isVisible = false binding.buttonResume.isVisible = false
} }
} }
}.launchIn(scope) }.launchIn(lifecycleOwner.lifecycleScope)
} }
onViewRecycled { onViewRecycled {

View File

@@ -6,7 +6,6 @@ import android.os.Bundle
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import coil.ImageLoader import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -27,7 +26,7 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityDownloadsBinding.inflate(layoutInflater)) setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
val adapter = DownloadsAdapter(lifecycleScope, coil) val adapter = DownloadsAdapter(this, coil)
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing) val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing)) binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)

View File

@@ -1,21 +1,21 @@
package org.koitharu.kotatsu.download.ui package org.koitharu.kotatsu.download.ui
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlinx.coroutines.CoroutineScope
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.utils.progress.PausingProgressJob import org.koitharu.kotatsu.utils.progress.PausingProgressJob
typealias DownloadItem = PausingProgressJob<DownloadState> typealias DownloadItem = PausingProgressJob<DownloadState>
class DownloadsAdapter( class DownloadsAdapter(
scope: CoroutineScope, lifecycleOwner: LifecycleOwner,
coil: ImageLoader, coil: ImageLoader,
) : AsyncListDifferDelegationAdapter<DownloadItem>(DiffCallback()) { ) : AsyncListDifferDelegationAdapter<DownloadItem>(DiffCallback()) {
init { init {
delegatesManager.addDelegate(downloadItemAD(scope, coil)) delegatesManager.addDelegate(downloadItemAD(lifecycleOwner, coil))
setHasStableIds(true) setHasStableIds(true)
} }

View File

@@ -10,6 +10,7 @@ import android.text.format.DateUtils
import android.util.SparseArray import android.util.SparseArray
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat
@@ -28,7 +29,6 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.ellipsize import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DownloadNotification(private val context: Context) { class DownloadNotification(private val context: Context) {
@@ -37,18 +37,20 @@ class DownloadNotification(private val context: Context) {
private val states = SparseArray<DownloadState>() private val states = SparseArray<DownloadState>()
private val groupBuilder = NotificationCompat.Builder(context, CHANNEL_ID) private val groupBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
private val queueIntent = PendingIntent.getActivity( private val queueIntent = PendingIntentCompat.getActivity(
context, context,
REQUEST_QUEUE, REQUEST_QUEUE,
DownloadsActivity.newIntent(context), DownloadsActivity.newIntent(context),
PendingIntentCompat.FLAG_IMMUTABLE, 0,
false,
) )
private val localListIntent = PendingIntent.getActivity( private val localListIntent = PendingIntentCompat.getActivity(
context, context,
REQUEST_LIST_LOCAL, REQUEST_LIST_LOCAL,
MangaListActivity.newIntent(context, MangaSource.LOCAL), MangaListActivity.newIntent(context, MangaSource.LOCAL),
PendingIntentCompat.FLAG_IMMUTABLE, 0,
false,
) )
init { init {
@@ -78,31 +80,37 @@ class DownloadNotification(private val context: Context) {
progress++ progress++
context.getString(R.string.cancelling_) context.getString(R.string.cancelling_)
} }
is DownloadState.Done -> { is DownloadState.Done -> {
progress++ progress++
context.getString(R.string.download_complete) context.getString(R.string.download_complete)
} }
is DownloadState.Error -> { is DownloadState.Error -> {
isAllDone = false isAllDone = false
context.getString(R.string.error) context.getString(R.string.error)
} }
is DownloadState.PostProcessing -> { is DownloadState.PostProcessing -> {
progress++ progress++
isInProgress = true isInProgress = true
isAllDone = false isAllDone = false
context.getString(R.string.processing_) context.getString(R.string.processing_)
} }
is DownloadState.Preparing -> { is DownloadState.Preparing -> {
isAllDone = false isAllDone = false
isInProgress = true isInProgress = true
context.getString(R.string.preparing_) context.getString(R.string.preparing_)
} }
is DownloadState.Progress -> { is DownloadState.Progress -> {
isAllDone = false isAllDone = false
isInProgress = true isInProgress = true
progress += state.percent progress += state.percent
context.getString(R.string.percent_string_pattern, (state.percent * 100).format()) context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
} }
is DownloadState.Queued -> { is DownloadState.Queued -> {
isAllDone = false isAllDone = false
isInProgress = true isInProgress = true
@@ -158,21 +166,23 @@ class DownloadNotification(private val context: Context) {
private val cancelAction = NotificationCompat.Action( private val cancelAction = NotificationCompat.Action(
materialR.drawable.material_ic_clear_black_24dp, materialR.drawable.material_ic_clear_black_24dp,
context.getString(android.R.string.cancel), context.getString(android.R.string.cancel),
PendingIntent.getBroadcast( PendingIntentCompat.getBroadcast(
context, context,
startId * 2, startId * 2,
DownloadService.getCancelIntent(startId), DownloadService.getCancelIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE, PendingIntent.FLAG_CANCEL_CURRENT,
false,
), ),
) )
private val retryAction = NotificationCompat.Action( private val retryAction = NotificationCompat.Action(
R.drawable.ic_restart_black, R.drawable.ic_restart_black,
context.getString(R.string.try_again), context.getString(R.string.try_again),
PendingIntent.getBroadcast( PendingIntentCompat.getBroadcast(
context, context,
startId * 2 + 1, startId * 2 + 1,
DownloadService.getResumeIntent(startId), DownloadService.getResumeIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE, PendingIntent.FLAG_CANCEL_CURRENT,
false,
), ),
) )
@@ -213,6 +223,7 @@ class DownloadNotification(private val context: Context) {
builder.setOngoing(true) builder.setOngoing(true)
builder.priority = NotificationCompat.PRIORITY_DEFAULT builder.priority = NotificationCompat.PRIORITY_DEFAULT
} }
is DownloadState.Done -> { is DownloadState.Done -> {
builder.setProgress(0, 0, false) builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.download_complete)) builder.setContentText(context.getString(R.string.download_complete))
@@ -226,6 +237,7 @@ class DownloadNotification(private val context: Context) {
builder.setWhen(System.currentTimeMillis()) builder.setWhen(System.currentTimeMillis())
builder.priority = NotificationCompat.PRIORITY_DEFAULT builder.priority = NotificationCompat.PRIORITY_DEFAULT
} }
is DownloadState.Error -> { is DownloadState.Error -> {
val message = state.error.getDisplayMessage(context.resources) val message = state.error.getDisplayMessage(context.resources)
builder.setProgress(0, 0, false) builder.setProgress(0, 0, false)
@@ -244,6 +256,7 @@ class DownloadNotification(private val context: Context) {
} }
builder.priority = NotificationCompat.PRIORITY_DEFAULT builder.priority = NotificationCompat.PRIORITY_DEFAULT
} }
is DownloadState.PostProcessing -> { is DownloadState.PostProcessing -> {
builder.setProgress(1, 0, true) builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.processing_)) builder.setContentText(context.getString(R.string.processing_))
@@ -251,6 +264,7 @@ class DownloadNotification(private val context: Context) {
builder.setOngoing(true) builder.setOngoing(true)
builder.priority = NotificationCompat.PRIORITY_DEFAULT builder.priority = NotificationCompat.PRIORITY_DEFAULT
} }
is DownloadState.Queued -> { is DownloadState.Queued -> {
builder.setProgress(0, 0, false) builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.queued)) builder.setContentText(context.getString(R.string.queued))
@@ -259,6 +273,7 @@ class DownloadNotification(private val context: Context) {
builder.addAction(cancelAction) builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_LOW builder.priority = NotificationCompat.PRIORITY_LOW
} }
is DownloadState.Preparing -> { is DownloadState.Preparing -> {
builder.setProgress(1, 0, true) builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.preparing_)) builder.setContentText(context.getString(R.string.preparing_))
@@ -267,6 +282,7 @@ class DownloadNotification(private val context: Context) {
builder.addAction(cancelAction) builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_DEFAULT builder.priority = NotificationCompat.PRIORITY_DEFAULT
} }
is DownloadState.Progress -> { is DownloadState.Progress -> {
builder.setProgress(state.max, state.progress, false) builder.setProgress(state.max, state.progress, false)
val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format()) val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
@@ -302,11 +318,12 @@ class DownloadNotification(private val context: Context) {
manager.notify(ID_GROUP, notification) manager.notify(ID_GROUP, notification)
} }
private fun createMangaIntent(context: Context, manga: Manga) = PendingIntent.getActivity( private fun createMangaIntent(context: Context, manga: Manga) = PendingIntentCompat.getActivity(
context, context,
manga.hashCode(), manga.hashCode(),
DetailsActivity.newIntent(context, manga), DetailsActivity.newIntent(context, manga),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE, PendingIntent.FLAG_CANCEL_CURRENT,
false,
) )
companion object { companion object {

View File

@@ -44,12 +44,11 @@ import kotlin.collections.set
@AndroidEntryPoint @AndroidEntryPoint
class DownloadService : BaseService() { class DownloadService : BaseService() {
private lateinit var downloadManager: DownloadManager
private lateinit var downloadNotification: DownloadNotification private lateinit var downloadNotification: DownloadNotification
private lateinit var wakeLock: PowerManager.WakeLock private lateinit var wakeLock: PowerManager.WakeLock
@Inject @Inject
lateinit var downloadManagerFactory: DownloadManager.Factory lateinit var downloadManager: DownloadManager
private val jobs = LinkedHashMap<Int, PausingProgressJob<DownloadState>>() private val jobs = LinkedHashMap<Int, PausingProgressJob<DownloadState>>()
private val jobCount = MutableStateFlow(0) private val jobCount = MutableStateFlow(0)
@@ -61,7 +60,6 @@ class DownloadService : BaseService() {
downloadNotification = DownloadNotification(this) downloadNotification = DownloadNotification(this)
wakeLock = (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager) wakeLock = (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
downloadManager = downloadManagerFactory.create(lifecycleScope)
wakeLock.acquire(TimeUnit.HOURS.toMillis(8)) wakeLock.acquire(TimeUnit.HOURS.toMillis(8))
DownloadNotification.createChannel(this) DownloadNotification.createChannel(this)
startForeground(DownloadNotification.ID_GROUP, downloadNotification.buildGroupNotification()) startForeground(DownloadNotification.ID_GROUP, downloadNotification.buildGroupNotification())

View File

@@ -13,16 +13,15 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.ReversibleAction import org.koitharu.kotatsu.base.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.base.ui.util.SpanSizeResolver import org.koitharu.kotatsu.base.ui.util.SpanSizeResolver
import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.databinding.FragmentExploreBinding import org.koitharu.kotatsu.databinding.FragmentExploreBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.explore.ui.adapter.ExploreAdapter import org.koitharu.kotatsu.explore.ui.adapter.ExploreAdapter
@@ -30,14 +29,12 @@ import org.koitharu.kotatsu.explore.ui.adapter.ExploreListEventListener
import org.koitharu.kotatsu.explore.ui.model.ExploreItem import org.koitharu.kotatsu.explore.ui.model.ExploreItem
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.history.ui.HistoryActivity import org.koitharu.kotatsu.history.ui.HistoryActivity
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
import org.koitharu.kotatsu.utils.ext.addMenuProvider import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -75,9 +72,9 @@ class ExploreFragment :
viewModel.content.observe(viewLifecycleOwner) { viewModel.content.observe(viewLifecycleOwner) {
exploreAdapter?.items = it exploreAdapter?.items = it
} }
viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga) viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga)
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone) viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged) viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged)
} }
@@ -130,32 +127,11 @@ class ExploreFragment :
override fun onEmptyActionClick() = onManageClick(requireView()) override fun onEmptyActionClick() = onManageClick(requireView())
private fun onError(e: Throwable) {
val snackbar = Snackbar.make(
binding.recyclerView,
e.getDisplayMessage(resources),
Snackbar.LENGTH_SHORT,
)
snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav
snackbar.show()
}
private fun onOpenManga(manga: Manga) { private fun onOpenManga(manga: Manga) {
val intent = DetailsActivity.newIntent(context ?: return, manga) val intent = DetailsActivity.newIntent(context ?: return, manga)
startActivity(intent) startActivity(intent)
} }
private fun onActionDone(action: ReversibleAction) {
val handle = action.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
val snackbar = Snackbar.make(binding.recyclerView, action.stringResId, length)
if (handle != null) {
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
}
snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav
snackbar.show()
}
private fun onGridModeChanged(isGrid: Boolean) { private fun onGridModeChanged(isGrid: Boolean) {
binding.recyclerView.layoutManager = if (isGrid) { binding.recyclerView.layoutManager = if (isGrid) {
GridLayoutManager(requireContext(), 4).also { lm -> GridLayoutManager(requireContext(), 4).also { lm ->

View File

@@ -25,7 +25,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -50,7 +49,7 @@ class ExploreViewModel @Inject constructor(
} else { } else {
createContentFlow() createContentFlow()
} }
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(ExploreItem.Loading)) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(ExploreItem.Loading))
fun openRandom() { fun openRandom() {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {

View File

@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.setTextAndVisible import org.koitharu.kotatsu.utils.ext.setTextAndVisible
import org.koitharu.kotatsu.utils.ext.source
import org.koitharu.kotatsu.utils.image.FaviconFallbackDrawable import org.koitharu.kotatsu.utils.image.FaviconFallbackDrawable
fun exploreButtonsAD( fun exploreButtonsAD(
@@ -76,11 +77,11 @@ fun exploreSourceListItemAD(
bind { bind {
binding.textViewTitle.text = item.source.title binding.textViewTitle.text = item.source.title
val fallbackIcon = FaviconFallbackDrawable(context, item.source.name) val fallbackIcon = FaviconFallbackDrawable(context, item.source.name)
binding.imageViewIcon.newImageRequest(item.source.faviconUri(), item.source)?.run { binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
fallback(fallbackIcon) fallback(fallbackIcon)
placeholder(fallbackIcon) placeholder(fallbackIcon)
error(fallbackIcon) error(fallbackIcon)
lifecycle(lifecycleOwner) source(item.source)
enqueueWith(coil) enqueueWith(coil)
} }
} }
@@ -107,11 +108,11 @@ fun exploreSourceGridItemAD(
bind { bind {
binding.textViewTitle.text = item.source.title binding.textViewTitle.text = item.source.title
val fallbackIcon = FaviconFallbackDrawable(context, item.source.name) val fallbackIcon = FaviconFallbackDrawable(context, item.source.name)
binding.imageViewIcon.newImageRequest(item.source.faviconUri())?.run { binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
fallback(fallbackIcon) fallback(fallbackIcon)
placeholder(fallbackIcon) placeholder(fallbackIcon)
error(fallbackIcon) error(fallbackIcon)
lifecycle(lifecycleOwner) source(item.source)
enqueueWith(coil) enqueueWith(coil)
} }
} }

View File

@@ -23,8 +23,8 @@ abstract class FavouriteCategoriesDao {
suspend fun delete(id: Long) = setDeletedAt(id, System.currentTimeMillis()) suspend fun delete(id: Long) = setDeletedAt(id, System.currentTimeMillis())
@Query("UPDATE favourite_categories SET title = :title, `order` = :order, `track` = :tracker WHERE category_id = :id") @Query("UPDATE favourite_categories SET title = :title, `order` = :order, `track` = :tracker, `show_in_lib` = :onShelf WHERE category_id = :id")
abstract suspend fun update(id: Long, title: String, order: String, tracker: Boolean) abstract suspend fun update(id: Long, title: String, order: String, tracker: Boolean, onShelf: Boolean)
@Query("UPDATE favourite_categories SET `order` = :order WHERE category_id = :id") @Query("UPDATE favourite_categories SET `order` = :order WHERE category_id = :id")
abstract suspend fun updateOrder(id: Long, order: String) abstract suspend fun updateOrder(id: Long, order: String)

View File

@@ -1,12 +1,19 @@
package org.koitharu.kotatsu.favourites.domain package org.koitharu.kotatsu.favourites.domain
import androidx.room.withTransaction import androidx.room.withTransaction
import javax.inject.Inject import dagger.Reusable
import javax.inject.Singleton import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.base.domain.ReversibleHandle import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.* import org.koitharu.kotatsu.core.db.entity.SortOrder
import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
@@ -15,8 +22,9 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
import org.koitharu.kotatsu.utils.ext.mapItems import org.koitharu.kotatsu.utils.ext.mapItems
import javax.inject.Inject
@Singleton @Reusable
class FavouritesRepository @Inject constructor( class FavouritesRepository @Inject constructor(
private val db: MangaDatabase, private val db: MangaDatabase,
private val channels: TrackerNotificationChannels, private val channels: TrackerNotificationChannels,
@@ -83,7 +91,12 @@ class FavouritesRepository @Inject constructor(
return db.favouriteCategoriesDao.find(id.toInt()).toFavouriteCategory() return db.favouriteCategoriesDao.find(id.toInt()).toFavouriteCategory()
} }
suspend fun createCategory(title: String, sortOrder: SortOrder, isTrackerEnabled: Boolean): FavouriteCategory { suspend fun createCategory(
title: String,
sortOrder: SortOrder,
isTrackerEnabled: Boolean,
isVisibleOnShelf: Boolean,
): FavouriteCategory {
val entity = FavouriteCategoryEntity( val entity = FavouriteCategoryEntity(
title = title, title = title,
createdAt = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
@@ -92,7 +105,7 @@ class FavouritesRepository @Inject constructor(
order = sortOrder.name, order = sortOrder.name,
track = isTrackerEnabled, track = isTrackerEnabled,
deletedAt = 0L, deletedAt = 0L,
isVisibleInLibrary = true, isVisibleInLibrary = isVisibleOnShelf,
) )
val id = db.favouriteCategoriesDao.insert(entity) val id = db.favouriteCategoriesDao.insert(entity)
val category = entity.toFavouriteCategory(id) val category = entity.toFavouriteCategory(id)
@@ -100,8 +113,14 @@ class FavouritesRepository @Inject constructor(
return category return category
} }
suspend fun updateCategory(id: Long, title: String, sortOrder: SortOrder, isTrackerEnabled: Boolean) { suspend fun updateCategory(
db.favouriteCategoriesDao.update(id, title, sortOrder.name, isTrackerEnabled) id: Long,
title: String,
sortOrder: SortOrder,
isTrackerEnabled: Boolean,
isVisibleOnShelf: Boolean,
) {
db.favouriteCategoriesDao.update(id, title, sortOrder.name, isTrackerEnabled, isVisibleOnShelf)
} }
suspend fun updateCategory(id: Long, isVisibleInLibrary: Boolean) { suspend fun updateCategory(id: Long, isVisibleInLibrary: Boolean) {
@@ -123,8 +142,8 @@ class FavouritesRepository @Inject constructor(
suspend fun removeCategories(ids: Collection<Long>) { suspend fun removeCategories(ids: Collection<Long>) {
db.withTransaction { db.withTransaction {
for (id in ids) { for (id in ids) {
db.favouriteCategoriesDao.delete(id)
db.favouritesDao.deleteAll(id) db.favouritesDao.deleteAll(id)
db.favouriteCategoriesDao.delete(id)
} }
} }
// run after transaction success // run after transaction success

View File

@@ -18,11 +18,11 @@ import androidx.core.view.updatePadding
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.ListSelectionController import org.koitharu.kotatsu.base.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
import org.koitharu.kotatsu.favourites.ui.FavouritesActivity import org.koitharu.kotatsu.favourites.ui.FavouritesActivity
@@ -31,7 +31,6 @@ import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEdit
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import javax.inject.Inject import javax.inject.Inject
@@ -72,7 +71,7 @@ class FavouriteCategoriesActivity :
onBackPressedDispatcher.addCallback(exitReorderModeCallback) onBackPressedDispatcher.addCallback(exitReorderModeCallback)
viewModel.detalizedCategories.observe(this, ::onCategoriesChanged) viewModel.detalizedCategories.observe(this, ::onCategoriesChanged)
viewModel.onError.observe(this, ::onError) viewModel.onError.observe(this, SnackbarErrorObserver(binding.recyclerView, null))
viewModel.isInReorderMode.observe(this, ::onReorderModeChanged) viewModel.isInReorderMode.observe(this, ::onReorderModeChanged)
} }
@@ -146,11 +145,6 @@ class FavouriteCategoriesActivity :
invalidateOptionsMenu() invalidateOptionsMenu()
} }
private fun onError(e: Throwable) {
Snackbar.make(binding.recyclerView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG)
.show()
}
private fun onReorderModeChanged(isReorderMode: Boolean) { private fun onReorderModeChanged(isReorderMode: Boolean) {
val transition = Fade().apply { val transition = Fade().apply {
duration = resources.getInteger(android.R.integer.config_shortAnimTime).toLong() duration = resources.getInteger(android.R.integer.config_shortAnimTime).toLong()

View File

@@ -3,8 +3,6 @@ package org.koitharu.kotatsu.favourites.ui.categories
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.*
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -16,9 +14,11 @@ import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.mapItems import org.koitharu.kotatsu.utils.ext.mapItems
import org.koitharu.kotatsu.utils.ext.requireValue import org.koitharu.kotatsu.utils.ext.requireValue
import java.util.Collections
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class FavouritesCategoriesViewModel @Inject constructor( class FavouritesCategoriesViewModel @Inject constructor(
@@ -39,7 +39,7 @@ class FavouritesCategoriesViewModel @Inject constructor(
category = it, category = it,
isReorderMode = false, isReorderMode = false,
) )
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val detalizedCategories = combine( val detalizedCategories = combine(
repository.observeCategoriesWithCovers(), repository.observeCategoriesWithCovers(),
@@ -62,7 +62,7 @@ class FavouritesCategoriesViewModel @Inject constructor(
), ),
) )
} }
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
fun deleteCategory(id: Long) { fun deleteCategory(id: Long) {
launchJob { launchJob {

View File

@@ -4,12 +4,12 @@ import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlin.jvm.internal.Intrinsics
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import kotlin.jvm.internal.Intrinsics
class CategoriesAdapter( class CategoriesAdapter(
coil: ImageLoader, coil: ImageLoader,
@@ -20,7 +20,7 @@ class CategoriesAdapter(
init { init {
delegatesManager.addDelegate(categoryAD(coil, lifecycleOwner, onItemClickListener)) delegatesManager.addDelegate(categoryAD(coil, lifecycleOwner, onItemClickListener))
.addDelegate(emptyStateListAD(coil, listListener)) .addDelegate(emptyStateListAD(coil, lifecycleOwner, listListener))
.addDelegate(loadingStateAD()) .addDelegate(loadingStateAD())
} }
@@ -31,6 +31,7 @@ class CategoriesAdapter(
oldItem is CategoryListModel && newItem is CategoryListModel -> { oldItem is CategoryListModel && newItem is CategoryListModel -> {
oldItem.category.id == newItem.category.id oldItem.category.id == newItem.category.id
} }
else -> oldItem.javaClass == newItem.javaClass else -> oldItem.javaClass == newItem.javaClass
} }
} }
@@ -52,6 +53,7 @@ class CategoriesAdapter(
super.getChangePayload(oldItem, newItem) super.getChangePayload(oldItem, newItem)
} }
} }
else -> super.getChangePayload(oldItem, newItem) else -> super.getChangePayload(oldItem, newItem)
} }
} }

View File

@@ -77,13 +77,12 @@ fun categoryAD(
) )
} }
repeat(coverViews.size) { i -> repeat(coverViews.size) { i ->
coverViews[i].newImageRequest(item.covers.getOrNull(i))?.run { coverViews[i].newImageRequest(lifecycleOwner, item.covers.getOrNull(i))?.run {
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(fallback) fallback(fallback)
crossfade(crossFadeDuration * (i + 1)) crossfade(crossFadeDuration * (i + 1))
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)
allowRgb565(true) allowRgb565(true)
lifecycle(lifecycleOwner)
enqueueWith(coil) enqueueWith(coil)
} }
} }

View File

@@ -4,43 +4,37 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.text.Editable import android.text.Editable
import android.text.TextWatcher
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.Filter import android.widget.Filter
import androidx.activity.viewModels
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import com.google.android.material.R as materialR
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.util.DefaultTextWatcher
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.titleRes import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.databinding.ActivityCategoryEditBinding import org.koitharu.kotatsu.databinding.ActivityCategoryEditBinding
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getSerializableCompat import org.koitharu.kotatsu.utils.ext.getSerializableCompat
import com.google.android.material.R as materialR
@AndroidEntryPoint @AndroidEntryPoint
class FavouritesCategoryEditActivity : class FavouritesCategoryEditActivity :
BaseActivity<ActivityCategoryEditBinding>(), BaseActivity<ActivityCategoryEditBinding>(),
AdapterView.OnItemClickListener, AdapterView.OnItemClickListener,
View.OnClickListener, View.OnClickListener,
TextWatcher { DefaultTextWatcher {
@Inject private val viewModel by viewModels<FavouritesCategoryEditViewModel>()
lateinit var viewModelFactory: FavouritesCategoryEditViewModel.Factory
private val viewModel by assistedViewModels<FavouritesCategoryEditViewModel> {
viewModelFactory.create(intent.getLongExtra(EXTRA_ID, NO_ID))
}
private var selectedSortOrder: SortOrder? = null private var selectedSortOrder: SortOrder? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -83,14 +77,11 @@ class FavouritesCategoryEditActivity :
title = binding.editName.text?.toString()?.trim().orEmpty(), title = binding.editName.text?.toString()?.trim().orEmpty(),
sortOrder = getSelectedSortOrder(), sortOrder = getSelectedSortOrder(),
isTrackerEnabled = binding.switchTracker.isChecked, isTrackerEnabled = binding.switchTracker.isChecked,
isVisibleOnShelf = binding.switchShelf.isChecked,
) )
} }
} }
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) { override fun afterTextChanged(s: Editable?) {
binding.buttonDone.isEnabled = !s.isNullOrBlank() binding.buttonDone.isEnabled = !s.isNullOrBlank()
} }
@@ -122,6 +113,9 @@ class FavouritesCategoryEditActivity :
val sortText = getString((category?.order ?: SortOrder.NEWEST).titleRes) val sortText = getString((category?.order ?: SortOrder.NEWEST).titleRes)
binding.editSort.setText(sortText, false) binding.editSort.setText(sortText, false)
binding.switchTracker.isChecked = category?.isTrackingEnabled ?: true binding.switchTracker.isChecked = category?.isTrackingEnabled ?: true
binding.switchTracker.jumpDrawablesToCurrentState()
binding.switchShelf.isChecked = category?.isVisibleInLibrary ?: true
binding.switchShelf.jumpDrawablesToCurrentState()
} }
private fun onError(e: Throwable) { private fun onError(e: Throwable) {
@@ -133,6 +127,7 @@ class FavouritesCategoryEditActivity :
binding.editSort.isEnabled = !isLoading binding.editSort.isEnabled = !isLoading
binding.editName.isEnabled = !isLoading binding.editName.isEnabled = !isLoading
binding.switchTracker.isEnabled = !isLoading binding.switchTracker.isEnabled = !isLoading
binding.switchShelf.isEnabled = !isLoading
if (isLoading) { if (isLoading) {
binding.textViewError.isVisible = false binding.textViewError.isVisible = false
} }
@@ -167,9 +162,9 @@ class FavouritesCategoryEditActivity :
companion object { companion object {
private const val EXTRA_ID = "id" const val EXTRA_ID = "id"
const val NO_ID = -1L
private const val KEY_SORT_ORDER = "sort" private const val KEY_SORT_ORDER = "sort"
private const val NO_ID = -1L
fun newIntent(context: Context, id: Long = NO_ID): Intent { fun newIntent(context: Context, id: Long = NO_ID): Intent {
return Intent(context, FavouritesCategoryEditActivity::class.java) return Intent(context, FavouritesCategoryEditActivity::class.java)

View File

@@ -1,27 +1,30 @@
package org.koitharu.kotatsu.favourites.ui.categories.edit package org.koitharu.kotatsu.favourites.ui.categories.edit
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.liveData import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.EXTRA_ID
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.NO_ID
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import javax.inject.Inject
private const val NO_ID = -1L @HiltViewModel
class FavouritesCategoryEditViewModel @Inject constructor(
class FavouritesCategoryEditViewModel @AssistedInject constructor( savedStateHandle: SavedStateHandle,
@Assisted private val categoryId: Long,
private val repository: FavouritesRepository, private val repository: FavouritesRepository,
private val settings: AppSettings, private val settings: AppSettings,
) : BaseViewModel() { ) : BaseViewModel() {
private val categoryId = savedStateHandle[EXTRA_ID] ?: NO_ID
val onSaved = SingleLiveEvent<Unit>() val onSaved = SingleLiveEvent<Unit>()
val category = MutableLiveData<FavouriteCategory?>() val category = MutableLiveData<FavouriteCategory?>()
@@ -30,12 +33,14 @@ class FavouritesCategoryEditViewModel @AssistedInject constructor(
} }
init { init {
launchLoadingJob { launchLoadingJob(Dispatchers.Default) {
category.value = if (categoryId != NO_ID) { category.postValue(
repository.getCategory(categoryId) if (categoryId != NO_ID) {
} else { repository.getCategory(categoryId)
null } else {
} null
},
)
} }
} }
@@ -43,21 +48,16 @@ class FavouritesCategoryEditViewModel @AssistedInject constructor(
title: String, title: String,
sortOrder: SortOrder, sortOrder: SortOrder,
isTrackerEnabled: Boolean, isTrackerEnabled: Boolean,
isVisibleOnShelf: Boolean,
) { ) {
launchLoadingJob { launchLoadingJob(Dispatchers.Default) {
check(title.isNotEmpty()) check(title.isNotEmpty())
if (categoryId == NO_ID) { if (categoryId == NO_ID) {
repository.createCategory(title, sortOrder, isTrackerEnabled) repository.createCategory(title, sortOrder, isTrackerEnabled, isVisibleOnShelf)
} else { } else {
repository.updateCategory(categoryId, title, sortOrder, isTrackerEnabled) repository.updateCategory(categoryId, title, sortOrder, isTrackerEnabled, isVisibleOnShelf)
} }
onSaved.call(Unit) onSaved.postCall(Unit)
} }
} }
@AssistedFactory
interface Factory {
fun create(categoryId: Long): FavouritesCategoryEditViewModel
}
} }

View File

@@ -8,8 +8,8 @@ import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
@@ -19,7 +19,6 @@ import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEdit
import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
@@ -30,14 +29,7 @@ class FavouriteCategoriesBottomSheet :
View.OnClickListener, View.OnClickListener,
Toolbar.OnMenuItemClickListener { Toolbar.OnMenuItemClickListener {
@Inject private val viewModel: MangaCategoriesViewModel by viewModels()
lateinit var viewModelFactory: MangaCategoriesViewModel.Factory
private val viewModel: MangaCategoriesViewModel by assistedViewModels {
viewModelFactory.create(
requireNotNull(arguments?.getParcelableArrayList<ParcelableManga>(KEY_MANGA_LIST)).map { it.manga },
)
}
private var adapter: MangaCategoriesAdapter? = null private var adapter: MangaCategoriesAdapter? = null
@@ -91,7 +83,7 @@ class FavouriteCategoriesBottomSheet :
companion object { companion object {
private const val TAG = "FavouriteCategoriesDialog" private const val TAG = "FavouriteCategoriesDialog"
private const val KEY_MANGA_LIST = "manga_list" const val KEY_MANGA_LIST = "manga_list"
fun show(fm: FragmentManager, manga: Manga) = Companion.show(fm, listOf(manga)) fun show(fm: FragmentManager, manga: Manga) = Companion.show(fm, listOf(manga))

View File

@@ -1,23 +1,27 @@
package org.koitharu.kotatsu.favourites.ui.categories.select package org.koitharu.kotatsu.favourites.ui.categories.select
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet.Companion.KEY_MANGA_LIST
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import javax.inject.Inject
class MangaCategoriesViewModel @AssistedInject constructor( @HiltViewModel
@Assisted private val manga: List<Manga>, class MangaCategoriesViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val favouritesRepository: FavouritesRepository, private val favouritesRepository: FavouritesRepository,
) : BaseViewModel() { ) : BaseViewModel() {
private val manga = requireNotNull(savedStateHandle.get<List<ParcelableManga>>(KEY_MANGA_LIST)).map { it.manga }
val content = combine( val content = combine(
favouritesRepository.observeCategories(), favouritesRepository.observeCategories(),
observeCategoriesIds(), observeCategoriesIds(),
@@ -29,7 +33,7 @@ class MangaCategoriesViewModel @AssistedInject constructor(
isChecked = it.id in checked, isChecked = it.id in checked,
) )
} }
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
fun setChecked(categoryId: Long, isChecked: Boolean) { fun setChecked(categoryId: Long, isChecked: Boolean) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
@@ -61,10 +65,4 @@ class MangaCategoriesViewModel @AssistedInject constructor(
result result
} }
} }
@AssistedFactory
interface Factory {
fun create(manga: List<Manga>): MangaCategoriesViewModel
}
} }

View File

@@ -6,6 +6,7 @@ import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.ListSelectionController import org.koitharu.kotatsu.base.ui.list.ListSelectionController
@@ -14,20 +15,12 @@ import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.addMenuProvider import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener { class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener {
@Inject override val viewModel by viewModels<FavouritesListViewModel>()
lateinit var viewModelFactory: FavouritesListViewModel.Factory
override val viewModel by assistedViewModels { viewModelFactory.create(categoryId) }
private val categoryId: Long
get() = arguments?.getLong(ARG_CATEGORY_ID) ?: NO_ID
override val isSwipeRefreshEnabled = false override val isSwipeRefreshEnabled = false
@@ -83,7 +76,7 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
companion object { companion object {
const val NO_ID = 0L const val NO_ID = 0L
private const val ARG_CATEGORY_ID = "category_id" const val ARG_CATEGORY_ID = "category_id"
fun newInstance(categoryId: Long) = FavouritesListFragment().withArgs(1) { fun newInstance(categoryId: Long) = FavouritesListFragment().withArgs(1) {
putLong(ARG_CATEGORY_ID, categoryId) putLong(ARG_CATEGORY_ID, categoryId)

View File

@@ -2,10 +2,9 @@ package org.koitharu.kotatsu.favourites.ui.list
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -13,8 +12,10 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.util.ReversibleAction import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
@@ -27,17 +28,21 @@ import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject
class FavouritesListViewModel @AssistedInject constructor( @HiltViewModel
@Assisted val categoryId: Long, class FavouritesListViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: FavouritesRepository, private val repository: FavouritesRepository,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val tagHighlighter: MangaTagHighlighter,
) : MangaListViewModel(settings), ListExtraProvider { ) : MangaListViewModel(settings), ListExtraProvider {
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
var categoryName: String? = null var categoryName: String? = null
private set private set
@@ -46,7 +51,7 @@ class FavouritesListViewModel @AssistedInject constructor(
} else { } else {
repository.observeCategory(categoryId) repository.observeCategory(categoryId)
.map { it?.order } .map { it?.order }
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null) .asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, null)
} }
override val content = combine( override val content = combine(
@@ -71,7 +76,7 @@ class FavouritesListViewModel @AssistedInject constructor(
), ),
) )
else -> list.toUi(mode, this) else -> list.toUi(mode, this, tagHighlighter)
} }
}.catch { }.catch {
emit(listOf(it.toErrorState(canRetry = false))) emit(listOf(it.toErrorState(canRetry = false)))
@@ -131,10 +136,4 @@ class FavouritesListViewModel @AssistedInject constructor(
PROGRESS_NONE PROGRESS_NONE
} }
} }
@AssistedFactory
interface Factory {
fun create(categoryId: Long): FavouritesListViewModel
}
} }

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.history.domain package org.koitharu.kotatsu.history.domain
import androidx.room.withTransaction import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChangedBy
@@ -30,6 +31,7 @@ import javax.inject.Inject
const val PROGRESS_NONE = -1f const val PROGRESS_NONE = -1f
@Reusable
class HistoryRepository @Inject constructor( class HistoryRepository @Inject constructor(
private val db: MangaDatabase, private val db: MangaDatabase,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
@@ -37,7 +39,7 @@ class HistoryRepository @Inject constructor(
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>, private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
) { ) {
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> { suspend fun getList(offset: Int, limit: Int): List<Manga> {
val entities = db.historyDao.findAll(offset, limit) val entities = db.historyDao.findAll(offset, limit)
return entities.map { it.manga.toManga(it.tags.toMangaTags()) } return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
} }
@@ -135,7 +137,7 @@ class HistoryRepository @Inject constructor(
/** /**
* Try to replace one manga with another one * Try to replace one manga with another one
* Useful for replacing saved manga on deleting it with remove source * Useful for replacing saved manga on deleting it with remote source
*/ */
suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) { suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) {
if (alternative == null || db.mangaDao.update(alternative.toEntity()) <= 0) { if (alternative == null || db.mangaDao.update(alternative.toEntity()) <= 0) {

View File

@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.util.ReversibleAction import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
@@ -38,6 +39,7 @@ class HistoryListViewModel @Inject constructor(
private val repository: HistoryRepository, private val repository: HistoryRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
private val tagHighlighter: MangaTagHighlighter,
) : MangaListViewModel(settings) { ) : MangaListViewModel(settings) {
val isGroupingEnabled = MutableLiveData<Boolean>() val isGroupingEnabled = MutableLiveData<Boolean>()
@@ -118,7 +120,7 @@ class HistoryListViewModel @Inject constructor(
val percent = if (showPercent) history.percent else PROGRESS_NONE val percent = if (showPercent) history.percent else PROGRESS_NONE
result += when (mode) { result += when (mode) {
ListMode.LIST -> manga.toListModel(counter, percent) ListMode.LIST -> manga.toListModel(counter, percent)
ListMode.DETAILED_LIST -> manga.toListDetailedModel(counter, percent) ListMode.DETAILED_LIST -> manga.toListDetailedModel(counter, percent, tagHighlighter)
ListMode.GRID -> manga.toGridModel(counter, percent) ListMode.GRID -> manga.toGridModel(counter, percent)
} }
} }

View File

@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityImageBinding import org.koitharu.kotatsu.databinding.ActivityImageBinding
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.utils.ext.indicator import org.koitharu.kotatsu.utils.ext.indicator
import javax.inject.Inject import javax.inject.Inject
@@ -57,7 +58,7 @@ class ImageActivity : BaseActivity<ActivityImageBinding>() {
.data(url) .data(url)
.memoryCachePolicy(CachePolicy.DISABLED) .memoryCachePolicy(CachePolicy.DISABLED)
.lifecycle(this) .lifecycle(this)
.tag(intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource) .tag(intent.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE))
.target(SsivTarget(binding.ssiv)) .target(SsivTarget(binding.ssiv))
.indicator(binding.progressBar) .indicator(binding.progressBar)
.enqueueWith(coil) .enqueueWith(coil)

View File

@@ -31,9 +31,8 @@ import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration
import org.koitharu.kotatsu.base.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.base.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.base.ui.util.ReversibleAction import org.koitharu.kotatsu.base.ui.util.ReversibleAction
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.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
@@ -55,7 +54,6 @@ import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.addMenuProvider import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.clearItemDecorations import org.koitharu.kotatsu.utils.ext.clearItemDecorations
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getThemeColor import org.koitharu.kotatsu.utils.ext.getThemeColor
import org.koitharu.kotatsu.utils.ext.measureHeight import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.resolveDp import org.koitharu.kotatsu.utils.ext.resolveDp
@@ -128,7 +126,7 @@ abstract class MangaListFragment :
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged)
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.content.observe(viewLifecycleOwner, ::onListChanged) viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone) viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
} }
@@ -175,18 +173,6 @@ abstract class MangaListFragment :
listAdapter?.setItems(list, listCommitCallback) listAdapter?.setItems(list, listCommitCallback)
} }
private fun onError(e: Throwable) {
if (e is CloudFlareProtectedException) {
CloudFlareDialog.newInstance(e.url, e.headers).show(childFragmentManager, CloudFlareDialog.TAG)
} else {
Snackbar.make(
binding.recyclerView,
e.getDisplayMessage(resources),
Snackbar.LENGTH_SHORT,
).show()
}
}
private fun onActionDone(action: ReversibleAction) { private fun onActionDone(action: ReversibleAction) {
val handle = action.handle val handle = action.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.list.ui.adapter package org.koitharu.kotatsu.list.ui.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding
@@ -12,6 +13,7 @@ import org.koitharu.kotatsu.utils.ext.setTextAndVisible
fun emptyStateListAD( fun emptyStateListAD(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
listener: ListStateHolderListener?, listener: ListStateHolderListener?,
) = adapterDelegateViewBinding<EmptyState, ListModel, ItemEmptyStateBinding>( ) = adapterDelegateViewBinding<EmptyState, ListModel, ItemEmptyStateBinding>(
{ inflater, parent -> ItemEmptyStateBinding.inflate(inflater, parent, false) }, { inflater, parent -> ItemEmptyStateBinding.inflate(inflater, parent, false) },
@@ -22,7 +24,7 @@ fun emptyStateListAD(
} }
bind { bind {
binding.icon.newImageRequest(item.icon)?.enqueueWith(coil) binding.icon.newImageRequest(lifecycleOwner, item.icon)?.enqueueWith(coil)
binding.textPrimary.setText(item.textPrimary) binding.textPrimary.setText(item.textPrimary)
binding.textSecondary.setTextAndVisible(item.textSecondary) binding.textSecondary.setTextAndVisible(item.textSecondary)
if (listener != null) { if (listener != null) {

View File

@@ -6,12 +6,13 @@ import org.koitharu.kotatsu.databinding.ItemHeader2Binding
import org.koitharu.kotatsu.list.ui.model.ListHeader2 import org.koitharu.kotatsu.list.ui.model.ListHeader2
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.isAnimationsEnabled
import org.koitharu.kotatsu.utils.ext.setTextAndVisible import org.koitharu.kotatsu.utils.ext.setTextAndVisible
fun listHeader2AD( fun listHeader2AD(
listener: MangaListListener, listener: MangaListListener,
) = adapterDelegateViewBinding<ListHeader2, ListModel, ItemHeader2Binding>( ) = adapterDelegateViewBinding<ListHeader2, ListModel, ItemHeader2Binding>(
{ layoutInflater, parent -> ItemHeader2Binding.inflate(layoutInflater, parent, false) } { layoutInflater, parent -> ItemHeader2Binding.inflate(layoutInflater, parent, false) },
) { ) {
var ignoreChecking = false var ignoreChecking = false
@@ -26,11 +27,15 @@ fun listHeader2AD(
bind { payloads -> bind { payloads ->
if (payloads.isNotEmpty()) { if (payloads.isNotEmpty()) {
binding.scrollView.smoothScrollTo(0, 0) if (context.isAnimationsEnabled) {
binding.scrollView.smoothScrollTo(0, 0)
} else {
binding.scrollView.scrollTo(0, 0)
}
} }
ignoreChecking = true ignoreChecking = true
binding.chipsTags.setChips(item.chips) // TODO use recyclerview binding.chipsTags.setChips(item.chips) // TODO use recyclerview
ignoreChecking = false ignoreChecking = false
binding.textViewFilter.setTextAndVisible(item.sortOrder?.titleRes ?: 0) binding.textViewFilter.setTextAndVisible(item.sortOrder?.titleRes ?: 0)
} }
} }

View File

@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.disposeImageRequest import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.source
import org.koitharu.kotatsu.utils.image.CoverSizeResolver import org.koitharu.kotatsu.utils.image.CoverSizeResolver
fun mangaGridItemAD( fun mangaGridItemAD(
@@ -38,13 +39,13 @@ fun mangaGridItemAD(
bind { payloads -> bind { payloads ->
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title
binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads) binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
binding.imageViewCover.newImageRequest(item.coverUrl, item.source)?.run { binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run {
size(CoverSizeResolver(binding.imageViewCover)) size(CoverSizeResolver(binding.imageViewCover))
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)
allowRgb565(true) allowRgb565(true)
lifecycle(lifecycleOwner) source(item.source)
enqueueWith(coil) enqueueWith(coil)
} }
badge = itemView.bindBadge(badge, item.counter) badge = itemView.bindBadge(badge, item.counter)

View File

@@ -4,9 +4,15 @@ import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlin.jvm.internal.Intrinsics
import org.koitharu.kotatsu.core.ui.DateTimeAgo import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListHeader2
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import kotlin.jvm.internal.Intrinsics
open class MangaListAdapter( open class MangaListAdapter(
coil: ImageLoader, coil: ImageLoader,
@@ -24,7 +30,7 @@ open class MangaListAdapter(
.addDelegate(ITEM_TYPE_DATE, relatedDateItemAD()) .addDelegate(ITEM_TYPE_DATE, relatedDateItemAD())
.addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(listener)) .addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(listener))
.addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(listener)) .addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(listener))
.addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD(coil, listener)) .addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listener))
.addDelegate(ITEM_TYPE_HEADER, listHeaderAD(listener)) .addDelegate(ITEM_TYPE_HEADER, listHeaderAD(listener))
.addDelegate(ITEM_TYPE_HEADER_2, listHeader2AD(listener)) .addDelegate(ITEM_TYPE_HEADER_2, listHeader2AD(listener))
} }
@@ -35,20 +41,25 @@ open class MangaListAdapter(
oldItem is MangaListModel && newItem is MangaListModel -> { oldItem is MangaListModel && newItem is MangaListModel -> {
oldItem.id == newItem.id oldItem.id == newItem.id
} }
oldItem is MangaListDetailedModel && newItem is MangaListDetailedModel -> { oldItem is MangaListDetailedModel && newItem is MangaListDetailedModel -> {
oldItem.id == newItem.id oldItem.id == newItem.id
} }
oldItem is MangaGridModel && newItem is MangaGridModel -> { oldItem is MangaGridModel && newItem is MangaGridModel -> {
oldItem.id == newItem.id oldItem.id == newItem.id
} }
oldItem is DateTimeAgo && newItem is DateTimeAgo -> { oldItem is DateTimeAgo && newItem is DateTimeAgo -> {
oldItem == newItem oldItem == newItem
} }
oldItem is ListHeader && newItem is ListHeader -> { oldItem is ListHeader && newItem is ListHeader -> {
oldItem.textRes == newItem.textRes && oldItem.textRes == newItem.textRes &&
oldItem.text == newItem.text && oldItem.text == newItem.text &&
oldItem.dateTimeAgo == newItem.dateTimeAgo oldItem.dateTimeAgo == newItem.dateTimeAgo
} }
else -> oldItem.javaClass == newItem.javaClass else -> oldItem.javaClass == newItem.javaClass
} }
@@ -65,6 +76,7 @@ open class MangaListAdapter(
} else { } else {
} }
} }
is ListHeader2 -> Unit is ListHeader2 -> Unit
else -> super.getChangePayload(oldItem, newItem) else -> super.getChangePayload(oldItem, newItem)
} }

View File

@@ -17,6 +17,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.disposeImageRequest import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.source
import org.koitharu.kotatsu.utils.ext.textAndVisible import org.koitharu.kotatsu.utils.ext.textAndVisible
import org.koitharu.kotatsu.utils.image.CoverSizeResolver import org.koitharu.kotatsu.utils.image.CoverSizeResolver
@@ -51,15 +52,18 @@ fun mangaListDetailedItemAD(
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title
binding.textViewSubtitle.textAndVisible = item.subtitle binding.textViewSubtitle.textAndVisible = item.subtitle
binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads) binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
binding.imageViewCover.newImageRequest(item.coverUrl, item.source)?.run { binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run {
size(CoverSizeResolver(binding.imageViewCover)) size(CoverSizeResolver(binding.imageViewCover))
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)
allowRgb565(true) allowRgb565(true)
lifecycle(lifecycleOwner) source(item.source)
enqueueWith(coil) enqueueWith(coil)
} }
if (payloads.isEmpty()) {
binding.scrollViewTags.scrollTo(0, 0)
}
binding.chipsTags.setChips(item.tags) binding.chipsTags.setChips(item.tags)
binding.ratingBar.isVisible = item.manga.hasRating binding.ratingBar.isVisible = item.manga.hasRating
binding.ratingBar.rating = binding.ratingBar.numStars * item.manga.rating binding.ratingBar.rating = binding.ratingBar.numStars * item.manga.rating

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.disposeImageRequest import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.source
import org.koitharu.kotatsu.utils.ext.textAndVisible import org.koitharu.kotatsu.utils.ext.textAndVisible
fun mangaListItemAD( fun mangaListItemAD(
@@ -34,12 +35,12 @@ fun mangaListItemAD(
bind { bind {
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title
binding.textViewSubtitle.textAndVisible = item.subtitle binding.textViewSubtitle.textAndVisible = item.subtitle
binding.imageViewCover.newImageRequest(item.coverUrl, item.source)?.run { binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run {
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)
allowRgb565(true) allowRgb565(true)
lifecycle(lifecycleOwner) source(item.source)
enqueueWith(coil) enqueueWith(coil)
} }
badge = itemView.bindBadge(badge, item.counter) badge = itemView.bindBadge(badge, item.counter)

View File

@@ -16,7 +16,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import java.text.Collator import java.text.Collator
@@ -37,7 +37,7 @@ class FilterCoordinator(
private var availableTagsDeferred = loadTagsAsync() private var availableTagsDeferred = loadTagsAsync()
val items: LiveData<List<FilterItem>> = getItemsFlow() val items: LiveData<List<FilterItem>> = getItemsFlow()
.asLiveDataDistinct(coroutineScope.coroutineContext + Dispatchers.Default, listOf(FilterItem.Loading)) .asFlowLiveData(coroutineScope.coroutineContext + Dispatchers.Default, listOf(FilterItem.Loading))
init { init {
observeState() observeState()

View File

@@ -4,6 +4,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.ListExtraProvider
@@ -13,7 +14,10 @@ import org.koitharu.kotatsu.utils.ext.ifZero
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.net.UnknownHostException import java.net.UnknownHostException
fun Manga.toListModel(counter: Int, progress: Float) = MangaListModel( fun Manga.toListModel(
counter: Int,
progress: Float,
) = MangaListModel(
id = id, id = id,
title = title, title = title,
subtitle = tags.joinToString(", ") { it.title }, subtitle = tags.joinToString(", ") { it.title },
@@ -23,7 +27,11 @@ fun Manga.toListModel(counter: Int, progress: Float) = MangaListModel(
progress = progress, progress = progress,
) )
fun Manga.toListDetailedModel(counter: Int, progress: Float) = MangaListDetailedModel( fun Manga.toListDetailedModel(
counter: Int,
progress: Float,
tagHighlighter: MangaTagHighlighter?,
) = MangaListDetailedModel(
id = id, id = id,
title = title, title = title,
subtitle = altTitle, subtitle = altTitle,
@@ -31,7 +39,15 @@ fun Manga.toListDetailedModel(counter: Int, progress: Float) = MangaListDetailed
manga = this, manga = this,
counter = counter, counter = counter,
progress = progress, progress = progress,
tags = tags.map { ChipsView.ChipModel(0, it.title, false, false, it) }, tags = tags.map {
ChipsView.ChipModel(
tint = tagHighlighter?.getTint(it) ?: 0,
title = it.title,
isCheckable = false,
isChecked = false,
data = it,
)
},
) )
fun Manga.toGridModel(counter: Int, progress: Float) = MangaGridModel( fun Manga.toGridModel(counter: Int, progress: Float) = MangaGridModel(
@@ -46,18 +62,21 @@ fun Manga.toGridModel(counter: Int, progress: Float) = MangaGridModel(
suspend fun List<Manga>.toUi( suspend fun List<Manga>.toUi(
mode: ListMode, mode: ListMode,
extraProvider: ListExtraProvider, extraProvider: ListExtraProvider,
): List<MangaItemModel> = toUi(ArrayList(size), mode, extraProvider) tagHighlighter: MangaTagHighlighter?,
): List<MangaItemModel> = toUi(ArrayList(size), mode, extraProvider, tagHighlighter)
fun List<Manga>.toUi( fun List<Manga>.toUi(
mode: ListMode, mode: ListMode,
): List<MangaItemModel> = toUi(ArrayList(size), mode) tagHighlighter: MangaTagHighlighter?,
): List<MangaItemModel> = toUi(ArrayList(size), mode, tagHighlighter)
fun <C : MutableCollection<in MangaItemModel>> List<Manga>.toUi( fun <C : MutableCollection<in MangaItemModel>> List<Manga>.toUi(
destination: C, destination: C,
mode: ListMode, mode: ListMode,
tagHighlighter: MangaTagHighlighter?,
): C = when (mode) { ): C = when (mode) {
ListMode.LIST -> mapTo(destination) { it.toListModel(0, PROGRESS_NONE) } ListMode.LIST -> mapTo(destination) { it.toListModel(0, PROGRESS_NONE) }
ListMode.DETAILED_LIST -> mapTo(destination) { it.toListDetailedModel(0, PROGRESS_NONE) } ListMode.DETAILED_LIST -> mapTo(destination) { it.toListDetailedModel(0, PROGRESS_NONE, tagHighlighter) }
ListMode.GRID -> mapTo(destination) { it.toGridModel(0, PROGRESS_NONE) } ListMode.GRID -> mapTo(destination) { it.toGridModel(0, PROGRESS_NONE) }
} }
@@ -65,13 +84,14 @@ suspend fun <C : MutableCollection<in MangaItemModel>> List<Manga>.toUi(
destination: C, destination: C,
mode: ListMode, mode: ListMode,
extraProvider: ListExtraProvider, extraProvider: ListExtraProvider,
tagHighlighter: MangaTagHighlighter?,
): C = when (mode) { ): C = when (mode) {
ListMode.LIST -> mapTo(destination) { ListMode.LIST -> mapTo(destination) {
it.toListModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id)) it.toListModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id))
} }
ListMode.DETAILED_LIST -> mapTo(destination) { ListMode.DETAILED_LIST -> mapTo(destination) {
it.toListDetailedModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id)) it.toListDetailedModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id), tagHighlighter)
} }
ListMode.GRID -> mapTo(destination) { ListMode.GRID -> mapTo(destination) {

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okio.buffer import okio.buffer
import okio.source import okio.source
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import java.util.zip.ZipFile import java.util.zip.ZipFile
class CbzFetcher( class CbzFetcher(

View File

@@ -1,15 +1,20 @@
package org.koitharu.kotatsu.local.data package org.koitharu.kotatsu.local.data
import java.io.File import java.io.File
import java.io.FileFilter
import java.io.FilenameFilter import java.io.FilenameFilter
import java.util.* import java.util.Locale
class CbzFilter : FilenameFilter { class CbzFilter : FileFilter, FilenameFilter {
override fun accept(dir: File, name: String): Boolean { override fun accept(dir: File, name: String): Boolean {
return isFileSupported(name) return isFileSupported(name)
} }
override fun accept(pathname: File?): Boolean {
return isFileSupported(pathname?.name ?: return false)
}
companion object { companion object {
fun isFileSupported(name: String): Boolean { fun isFileSupported(name: String): Boolean {

View File

@@ -1,27 +0,0 @@
package org.koitharu.kotatsu.local.data
import android.os.FileObserver
import java.io.File
import kotlinx.coroutines.channels.ProducerScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow
@Suppress("DEPRECATION")
class FlowFileObserver(
private val producerScope: ProducerScope<File>,
private val file: File,
) : FileObserver(file.absolutePath, CREATE or DELETE or CLOSE_WRITE) {
override fun onEvent(event: Int, path: String?) {
producerScope.trySendBlocking(
if (path == null) file else file.resolve(path),
)
}
}
fun File.observe() = callbackFlow {
val observer = FlowFileObserver(this, this@observe)
observer.startWatching()
awaitClose { observer.stopWatching() }
}

View File

@@ -0,0 +1,29 @@
package org.koitharu.kotatsu.local.data
import java.io.File
import java.io.FileFilter
import java.io.FilenameFilter
import java.util.Locale
import java.util.zip.ZipEntry
class ImageFileFilter : FilenameFilter, FileFilter {
override fun accept(dir: File, name: String): Boolean {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
return isExtensionValid(ext)
}
override fun accept(pathname: File?): Boolean {
val ext = pathname?.extension?.lowercase(Locale.ROOT) ?: return false
return isExtensionValid(ext)
}
fun accept(entry: ZipEntry): Boolean {
val ext = entry.name.substringAfterLast('.', "").lowercase(Locale.ROOT)
return isExtensionValid(ext)
}
private fun isExtensionValid(ext: String): Boolean {
return ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "webp"
}
}

View File

@@ -1,15 +1,16 @@
package org.koitharu.kotatsu.local.domain package org.koitharu.kotatsu.local.data
import java.io.File
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import java.io.File
class LocalManga( class LocalManga(
val manga: Manga,
val file: File, val file: File,
val manga: Manga,
) { ) {
var createdAt: Long = -1L var createdAt: Long = -1L
private set
get() { get() {
if (field == -1L) { if (field == -1L) {
field = file.lastModified() field = file.lastModified()
@@ -17,6 +18,15 @@ class LocalManga(
return field return field
} }
fun isMatchesQuery(query: String): Boolean {
return manga.title.contains(query, ignoreCase = true) ||
manga.altTitle?.contains(query, ignoreCase = true) == true
}
fun containsTags(tags: Set<MangaTag>): Boolean {
return manga.tags.containsAll(tags)
}
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false
@@ -34,15 +44,8 @@ class LocalManga(
result = 31 * result + file.hashCode() result = 31 * result + file.hashCode()
return result return result
} }
}
fun Collection<LocalManga>.unwrap(): List<Manga> = map { it.manga } override fun toString(): String {
return "LocalManga(${file.path}: ${manga.title})"
fun LocalManga.isMatchesQuery(query: String): Boolean { }
return manga.title.contains(query, ignoreCase = true) ||
manga.altTitle?.contains(query, ignoreCase = true) == true
}
fun LocalManga.containsTags(tags: Set<MangaTag>): Boolean {
return manga.tags.containsAll(tags)
} }

View File

@@ -4,10 +4,8 @@ import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.os.StatFs import android.os.StatFs
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asFlow
@@ -17,16 +15,19 @@ import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.Cache import okhttp3.Cache
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.util.observe
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.utils.ext.computeSize import org.koitharu.kotatsu.utils.ext.computeSize
import org.koitharu.kotatsu.utils.ext.getStorageName import org.koitharu.kotatsu.utils.ext.getStorageName
import java.io.File
import javax.inject.Inject
private const val DIR_NAME = "manga" private const val DIR_NAME = "manga"
private const val CACHE_DISK_PERCENTAGE = 0.02 private const val CACHE_DISK_PERCENTAGE = 0.02
private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB
private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
@Singleton @Reusable
class LocalStorageManager @Inject constructor( class LocalStorageManager @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val settings: AppSettings, private val settings: AppSettings,

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