Compare commits

..

149 Commits
v6.0 ... v6.2.2

Author SHA1 Message Date
Koitharu
74569615e3 Fix splash background 2023-10-18 10:55:01 +03:00
Koitharu
f3c320a90f Merge branch 'devel' of github.com:KotatsuApp/Kotatsu into devel 2023-10-18 10:02:13 +03:00
gallegonovato
a3012ab458 Translated using Weblate (Spanish)
Currently translated at 100.0% (498 of 498 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-10-18 09:59:52 +03:00
Макар Разин
6ec58879fd Translated using Weblate (Ukrainian)
Currently translated at 100.0% (498 of 498 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (498 of 498 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (498 of 498 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (498 of 498 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (498 of 498 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-10-18 09:59:52 +03:00
Koitharu
571cf08c53 Merge branch 'devel' of github.com:KotatsuApp/Kotatsu into devel 2023-10-18 09:50:52 +03:00
Koitharu
fca53eee7a Improve downloads list 2023-10-18 09:40:31 +03:00
Zakhar Timoshenko
ed9e2eb4d2 ActionMode and NavBar colors fix 2023-10-17 18:04:34 +03:00
Koitharu
c0e94f8415 Show chapters list in downloads 2023-10-17 12:19:44 +03:00
Koitharu
e172d619a1 Fix description expanding 2023-10-17 11:13:16 +03:00
Koitharu
d6c64fc638 Action to open online version of saved manga 2023-10-17 11:06:16 +03:00
Koitharu
37404cb9a6 UI improvements 2023-10-17 10:32:30 +03:00
Koitharu
9d5271ff26 Fix default branch selection #527 #528 2023-10-17 10:24:36 +03:00
Koitharu
5f59432e48 Handle NPE during network requests 2023-10-17 10:01:58 +03:00
Koitharu
5c082b5cdb Update parsers 2023-10-17 09:59:26 +03:00
Koitharu
32133d3358 Bump version 2023-10-16 17:48:25 +03:00
Koitharu
366e4f0da8 Disable mirror switching by default 2023-10-16 12:53:21 +03:00
Koitharu
3ef033c700 Update parsers 2023-10-16 12:46:39 +03:00
Koitharu
bef8e4652f Update acra credentials 2023-10-16 12:46:39 +03:00
Koitharu
8bfdf07a2f Fixes 2023-10-16 12:46:38 +03:00
Allan Nordhøy
f3e597275b Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/nb_NO/
Translation: Kotatsu/plurals
2023-10-16 12:40:38 +03:00
Koitharu
11feaae216 Translated using Weblate (Russian)
Currently translated at 100.0% (497 of 497 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
InfinityDouki56
fe2c1f9634 Translated using Weblate (Filipino)
Currently translated at 89.1% (443 of 497 strings)

Translated using Weblate (Filipino)

Currently translated at 89.2% (441 of 494 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
plum7x
0c7c6dc48a Translated using Weblate (Chinese (Traditional))
Currently translated at 99.5% (492 of 494 strings)

Co-authored-by: plum7x <plumgift@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
Bai
503652f024 Translated using Weblate (Turkish)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (494 of 494 strings)

Co-authored-by: Bai <batuhanakkurt000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/tr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-10-16 12:40:38 +03:00
ngocanhtve
0c4adc67ea Translated using Weblate (Vietnamese)
Currently translated at 85.1% (417 of 490 strings)

Co-authored-by: ngocanhtve <ngocanh.tve@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
BlackSpectrum
c7f5ce30b5 Translated using Weblate (Hindi)
Currently translated at 26.5% (130 of 490 strings)

Added translation using Weblate (Gujarati)

Co-authored-by: BlackSpectrum <tittan5000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
return_null
59d538824f Translated using Weblate (Chinese (Simplified))
Currently translated at 99.1% (486 of 490 strings)

Co-authored-by: return_null <demolang@dismail.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
Nayuki
de79f39d16 Translated using Weblate (Thai)
Currently translated at 71.2% (349 of 490 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
Clxff H3r4ld0
9792da3a5c Translated using Weblate (Indonesian)
Currently translated at 98.3% (482 of 490 strings)

Co-authored-by: Clxff H3r4ld0 <123844876+clxf12@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
Макар Разин
c2407e6e41 Translated using Weblate (Czech)
Currently translated at 90.1% (448 of 497 strings)

Translated using Weblate (Serbian)

Currently translated at 30.3% (151 of 497 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (497 of 497 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (497 of 497 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (497 of 497 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (490 of 490 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (490 of 490 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (490 of 490 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/cs/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
gallegonovato
7321eeaed9 Translated using Weblate (Spanish)
Currently translated at 100.0% (497 of 497 strings)

Translated using Weblate (Spanish)

Currently translated at 99.7% (493 of 494 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (490 of 490 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
Cookies
9876adf676 Translated using Weblate (Vietnamese)
Currently translated at 81.3% (398 of 489 strings)

Co-authored-by: Cookies <Nekop1845@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
Koitharu
d29e979fbf Add option to order favorites by reading progress 2023-10-13 16:31:51 +03:00
Koitharu
35baf4b58d Add option to order history and favorites by new chapters 2023-10-13 16:26:30 +03:00
Koitharu
97524d66f2 Fix pages thumbnails loading 2023-10-13 15:58:05 +03:00
Koitharu
5b53f8c27d Improve list options configuring 2023-10-13 14:21:28 +03:00
Koitharu
d4588570e6 Add option to disable new sources tip 2023-10-12 13:34:35 +03:00
Koitharu
cc2f9d4529 Improve chapters mapping 2023-10-12 13:24:51 +03:00
Koitharu
3def71ccc1 Merge branch 'feature/32-bit' into devel 2023-10-12 13:03:58 +03:00
Koitharu
b313c64648 Apply color config on-the-fly 2023-10-12 12:18:27 +03:00
Koitharu
f7e7c84317 Apply color config on-the-fly 2023-10-12 11:31:28 +03:00
Koitharu
ee1c532d53 Update progress 2023-10-12 10:42:28 +03:00
Koitharu
6993cec85e Fix new chapters counter in details screen 2023-10-12 10:42:28 +03:00
Koitharu
0b19f56215 Optimize finding saved manga for remote one 2023-10-12 10:42:28 +03:00
Zakhar Timoshenko
817ce7e8df 32-bit colors mode implementing 2023-10-11 21:18:13 +03:00
Zakhar Timoshenko
2b2498cb38 UI tweaks 2023-10-11 19:23:51 +03:00
Koitharu
e4efd0f696 Refactor manga details loading 2023-10-10 11:56:23 +03:00
Koitharu
fbb267e11c Update parsers 2023-10-09 10:10:55 +03:00
Koitharu
5740af05fa Update dependencies 2023-10-06 09:44:53 +03:00
Koitharu
ae2cc1dffc Add support for Dropped manga state 2023-10-04 15:48:28 +03:00
Koitharu
a5b9712e9f Update typography 2023-10-04 15:43:32 +03:00
Koitharu
c013e6e4f4 Adjust keyboard incognito mode 2023-10-04 15:35:14 +03:00
Koitharu
0249faa3f6 Remove bold from feed 2023-10-04 15:29:10 +03:00
Koitharu
9c52423dc0 Fix statusbar color 2023-10-04 15:26:14 +03:00
Eduardo Malaspina
1f7e5458ae Translated using Weblate (Spanish)
Currently translated at 100.0% (489 of 489 strings)

Co-authored-by: Eduardo Malaspina <vaio0@swismail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-10-04 15:12:03 +03:00
Макар Разин
b4d487b398 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (489 of 489 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (489 of 489 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (489 of 489 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-10-04 15:12:03 +03:00
Koitharu
0281f1eadb Incognito mode indicator 2023-10-04 15:00:46 +03:00
Koitharu
1bd9b655f9 Update parsers 2023-10-04 14:07:14 +03:00
Koitharu
ed87292921 Adaptive tags suggestion 2023-10-04 12:25:09 +03:00
Koitharu
861be7614e Fix back navigation 2023-10-04 11:44:49 +03:00
Koitharu
717fe8748a Fix suggestion notification text 2023-10-04 11:37:39 +03:00
Koitharu
c7a1312cd6 Fix check updates for saved manga #506 2023-10-02 16:49:20 +03:00
Koitharu
b2927854d4 Make keep screen on in reader optional 2023-10-02 16:39:14 +03:00
Koitharu
cfda150630 Fix crash on request pin shortcut 2023-10-02 15:22:35 +03:00
Koitharu
4fa1382ce9 Fix crash on download update 2023-10-02 15:15:44 +03:00
Koitharu
43075c52d1 Improve automatic mirror switching 2023-10-02 14:49:45 +03:00
Koitharu
87942747fc Update parsers 2023-10-02 13:34:40 +03:00
Koitharu
bb6cd73acd Update parsers 2023-09-30 17:44:13 +03:00
kuragehime
6790e5b0d4 Translated using Weblate (Japanese)
Currently translated at 100.0% (487 of 487 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-09-30 17:16:27 +03:00
Макар Разин
845c356a73 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (487 of 487 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (487 of 487 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/uk/
Translation: Kotatsu/Strings
2023-09-30 17:16:27 +03:00
return_null
34499ea77d Translated using Weblate (Chinese (Simplified))
Currently translated at 99.3% (484 of 487 strings)

Co-authored-by: return_null <demolang@dismail.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-09-30 17:16:27 +03:00
InfinityDouki56
6210864280 Translated using Weblate (Filipino)
Currently translated at 89.3% (435 of 487 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-09-30 17:16:27 +03:00
Crono
19084419c7 Translated using Weblate (Portuguese)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (487 of 487 strings)

Added translation using Weblate (Portuguese)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (487 of 487 strings)

Translated using Weblate (Portuguese)

Currently translated at 90.1% (439 of 487 strings)

Co-authored-by: Crono <cronoreader@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-09-30 17:16:27 +03:00
J. Lavoie
84ce4c508c Translated using Weblate (French)
Currently translated at 100.0% (487 of 487 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-09-20 12:34:26 +03:00
gallegonovato
0db8fafe61 Translated using Weblate (Spanish)
Currently translated at 100.0% (487 of 487 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-09-20 12:34:26 +03:00
Koitharu
fed241215e Update parsers 2023-09-20 12:34:12 +03:00
Koitharu
761f24daf9 Fix crashes 2023-09-20 09:40:20 +03:00
Koitharu
a435435496 Fix webtoon zoom controls visibility 2023-09-18 13:57:07 +03:00
Koitharu
81e8c25563 Reorder reader settings items 2023-09-18 13:51:02 +03:00
Koitharu
e3504c3b1e Translated using Weblate (Russian)
Currently translated at 100.0% (487 of 487 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-09-18 13:48:30 +03:00
Макар Разин
2601c12348 Translated using Weblate (Russian)
Currently translated at 100.0% (483 of 483 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (483 of 483 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-09-18 13:48:30 +03:00
Koitharu
138cf44e37 Fix crash with ActivityNotFoundException 2023-09-18 13:42:28 +03:00
Koitharu
65d83e0921 Fix search action #495 2023-09-18 13:34:08 +03:00
Koitharu
6e1cd05fa8 Zoom control buttons in reader 2023-09-18 13:25:53 +03:00
Koitharu
8398c01929 Improve keyboard control in reader 2023-09-18 12:49:37 +03:00
Koitharu
835c49ae79 Download updates directly 2023-09-15 13:34:13 +03:00
Koitharu
36065ccf6c Pin source shortcuts 2023-09-15 12:12:06 +03:00
Koitharu
4ab40566f7 Fix sync server address configuration 2023-09-15 11:14:06 +03:00
return_null
bf01a4d1ab Translated using Weblate (Chinese (Simplified))
Currently translated at 99.3% (480 of 483 strings)

Co-authored-by: return_null <demolang@dismail.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-09-14 13:41:58 +03:00
Koitharu
8dce9dcc3f Fix pages thumbnails loading 2023-09-14 13:38:02 +03:00
Koitharu
d872044252 Improve mouse interaction 2023-09-14 13:31:18 +03:00
Koitharu
f4313525c2 Update dependencies 2023-09-14 09:05:50 +03:00
Koitharu
4eb4ec7de0 Fix nsfw sources filtering 2023-09-12 19:33:49 +03:00
Koitharu
ecb4dd87d9 Update parsers 2023-09-12 19:28:45 +03:00
Nayuki
3d0f5f75cd Translated using Weblate (Thai)
Currently translated at 63.3% (306 of 483 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
Bander AL-shreef
c5462e8454 Translated using Weblate (Arabic)
Currently translated at 37.4% (181 of 483 strings)

Co-authored-by: Bander AL-shreef <bander.alshreef@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
return_null
5039e324fb Translated using Weblate (Chinese (Simplified))
Currently translated at 99.3% (480 of 483 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 93.1% (450 of 483 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 92.5% (447 of 483 strings)

Co-authored-by: return_null <demolang@dismail.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-09-12 18:31:26 +03:00
Макар Разин
b251b3e654 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (483 of 483 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (483 of 483 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (483 of 483 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-09-12 18:31:26 +03:00
gallegonovato
5f10070564 Translated using Weblate (Spanish)
Currently translated at 100.0% (483 of 483 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
Clxff H3r4ld0
3da6f80eb6 Translated using Weblate (Indonesian)
Currently translated at 100.0% (481 of 481 strings)

Co-authored-by: Clxff H3r4ld0 <123844876+clxf12@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
InfinityDouki56
4b2cfdb972 Translated using Weblate (Filipino)
Currently translated at 89.7% (428 of 477 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
kuragehime
51387ace7e Translated using Weblate (Japanese)
Currently translated at 100.0% (483 of 483 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (481 of 481 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (477 of 477 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
Koitharu
2bdb83ff28 Fix navigation reordering 2023-09-12 13:38:03 +03:00
Koitharu
a1b85433ec Fix bookmarks crash #492 2023-09-12 13:38:03 +03:00
Isira Seneviratne
ca5207c658 Use ancestors and descendants extensions 2023-09-09 17:52:12 +03:00
Koitharu
81de6124f0 Rethrow CancellationException from TrackWorkers #489 2023-09-09 17:42:47 +03:00
Koitharu
a93bc0ed5b Increase max autoscroll speed 2023-09-08 13:48:34 +03:00
Koitharu
a1b96ebbb5 Update parsers 2023-09-08 13:30:26 +03:00
Koitharu
6b93e49f56 Improve loading both local and remote manga 2023-09-08 13:07:26 +03:00
Koitharu
c88a9dff36 Handle enter press in search view 2023-09-08 09:17:04 +03:00
Koitharu
ca47c475d3 Avoid passing manga chapters via extras 2023-09-07 18:15:48 +03:00
Koitharu
8df7fa2729 Fix crashes 2023-09-07 16:40:07 +03:00
Koitharu
ea34abb1d7 Fix categories reordering 2023-09-07 14:06:52 +03:00
Koitharu
c4ff37350c Option to move manga source to top 2023-09-07 13:27:13 +03:00
Koitharu
95547a8d03 Configurable main navigation 2023-09-06 14:42:00 +03:00
Koitharu
4c2197aa5d Option to retry captcha resolving 2023-09-05 11:26:57 +03:00
Koitharu
a679b6775d Exclude captcha actvity from recent 2023-09-05 10:35:32 +03:00
Koitharu
d3e4e97c6f Fix tracker operations parallelism 2023-09-05 10:30:20 +03:00
Koitharu
d1b0af85c4 Update parsers 2023-09-05 10:30:20 +03:00
Koitharu
ce95e0657b Translated using Weblate (Russian)
Currently translated at 100.0% (476 of 476 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-09-05 10:30:08 +03:00
Nayuki
6bb159a6d9 Translated using Weblate (Thai)
Currently translated at 57.5% (274 of 476 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2023-09-05 10:30:08 +03:00
Макар Разин
a75583f750 Translated using Weblate (Belarusian)
Currently translated at 100.0% (476 of 476 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
2023-09-05 10:30:08 +03:00
Koitharu
fff9df9609 Fix categories and sources reordering 2023-09-03 17:39:52 +03:00
Koitharu
f9609edea5 Fallback to old systemUiVisibility in reader 2023-09-03 17:06:37 +03:00
Koitharu
f1245742c0 Merge branch 'File_creation_time' of github.com:Isira-Seneviratne/Kotatsu into Isira-Seneviratne-File_creation_time 2023-09-01 13:43:58 +03:00
Koitharu
42d933ba83 Bump version 2023-09-01 13:17:07 +03:00
Koitharu
4df644e21f Fix branch prediction 2023-09-01 12:02:31 +03:00
ViAnh
e4ba738c00 Use WeakHashMap to store views 2023-08-31 19:35:53 +03:00
ViAnh
b7f09243aa Avoid unnecessary child layout in webtoon recycler 2023-08-31 19:35:53 +03:00
ViAnh
50d4c41855 Fix webtoon under scale 2023-08-31 19:35:53 +03:00
Koitharu
67adc8b681 Fix widgets in dark theme 2023-08-31 19:28:25 +03:00
Koitharu
34fb4af9fe Fix color scheme preference 2023-08-31 19:11:29 +03:00
Koitharu
05241f73d9 Improve categories managing 2023-08-31 19:11:29 +03:00
Koitharu
d666e4b967 Fix small webtoon pages 2023-08-31 19:11:29 +03:00
Koitharu
b4bf607d3a Merge pull request #470 from Isira-Seneviratne/Data_classes 2023-08-31 09:17:03 +03:00
Isira Seneviratne
a417d5aaa9 Apply requested changes 2023-08-30 19:27:34 +05:30
Koitharu
4b6b2c3e12 Fix favorites selector 2023-08-30 14:43:57 +03:00
Koitharu
51300e30bd Improve favicon loading 2023-08-30 14:41:44 +03:00
Koitharu
399ac07fb3 Fix storage usage calculation 2023-08-30 14:21:09 +03:00
Eryk Michalak
eeba161235 Translated using Weblate (Polish)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Polish)

Currently translated at 95.5% (455 of 476 strings)

Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-08-30 12:45:08 +03:00
Nayuki
088a388812 Translated using Weblate (Thai)
Currently translated at 49.5% (236 of 476 strings)

Translated using Weblate (Thai)

Currently translated at 42.8% (204 of 476 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2023-08-30 12:45:08 +03:00
gallegonovato
943bba3ee8 Translated using Weblate (Spanish)
Currently translated at 100.0% (476 of 476 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-08-30 12:45:08 +03:00
Koitharu
18c3229200 Handle TooManyRequestsException during downloading 2023-08-29 20:23:20 +03:00
Koitharu
9b6f511ac6 Do not discard image requests in onViewRecycled 2023-08-29 17:35:00 +03:00
Isira Seneviratne
ad3b5dde91 Convert more classes to data classes 2023-08-27 07:10:32 +05:30
Isira Seneviratne
ded7cdb71e Obtain file creation time 2023-08-27 06:19:07 +05:30
Koitharu
74ca19a931 Improve widgets ui #457 2023-08-26 18:35:49 +03:00
Koitharu
2684a7384e Restore covers using interceptor 2023-08-26 16:44:09 +03:00
Koitharu
2c561824ef Fix default reader mode option #468 #466 2023-08-25 13:26:48 +03:00
358 changed files with 6084 additions and 3209 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 34
versionCode = 573
versionName = '6.0'
versionCode = 589
versionName = '6.2.2'
generatedDensities = []
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
ksp {
@@ -81,7 +81,7 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:3a76504380') {
implementation('com.github.KotatsuApp:kotatsu-parsers:0054d06e6e') {
exclude group: 'org.json', module: 'json'
}
@@ -89,21 +89,21 @@ dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.activity:activity-ktx:1.7.2'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.activity:activity-ktx:1.8.0'
implementation 'androidx.fragment:fragment-ktx:1.6.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-service:2.6.1'
implementation 'androidx.lifecycle:lifecycle-process:2.6.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-service:2.6.2'
implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.9.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.1'
implementation 'com.google.android.material:material:1.10.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.2'
// TODO https://issuetracker.google.com/issues/254846063
implementation 'androidx.work:work-runtime-ktx:2.8.1'
@@ -120,24 +120,24 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
implementation 'com.squareup.okio:okio:3.5.0'
implementation 'com.squareup.okio:okio:3.6.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'com.google.dagger:hilt-android:2.47'
kapt 'com.google.dagger:hilt-compiler:2.47'
implementation 'com.google.dagger:hilt-android:2.48.1'
kapt 'com.google.dagger:hilt-compiler:2.48.1'
implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.4.0'
implementation 'io.coil-kt:coil-svg:2.4.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:9b1d20be67'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:cf089a264d'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'
implementation 'ch.acra:acra-http:5.11.1'
implementation 'ch.acra:acra-dialog:5.11.1'
implementation 'ch.acra:acra-http:5.11.2'
implementation 'ch.acra:acra-dialog:5.11.2'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
@@ -155,6 +155,6 @@ dependencies {
androidTestImplementation 'androidx.room:room-testing:2.5.2'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.47'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.47'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.48.1'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.48.1'
}

View File

@@ -19,6 +19,7 @@
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
@@ -95,7 +96,12 @@
android:label="@string/search" />
<activity
android:name="org.koitharu.kotatsu.search.ui.MangaListActivity"
android:label="@string/search_manga" />
android:exported="true"
android:label="@string/manga_list">
<intent-filter>
<action android:name="${applicationId}.action.EXPLORE_MANGA" />
</intent-filter>
</activity>
<activity
android:name="org.koitharu.kotatsu.history.ui.HistoryActivity"
android:label="@string/history" />
@@ -138,6 +144,7 @@
android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity"
android:autoRemoveFromRecents="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize" />
<activity
@@ -148,13 +155,21 @@
android:name="org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity"
android:label="@string/manage_categories" />
<activity
android:name="org.koitharu.kotatsu.widget.shelf.ShelfConfigActivity"
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetConfigActivity"
android:exported="true"
android:label="@string/manga_shelf">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity
android:name="org.koitharu.kotatsu.widget.recent.RecentWidgetConfigActivity"
android:exported="true"
android:label="@string/recent_manga">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity
android:name="org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity"
android:label="@string/search" />
@@ -305,6 +320,13 @@
android:name="android.appwidget.provider"
android:resource="@xml/widget_recent" />
</receiver>
<receiver
android:name="org.koitharu.kotatsu.settings.about.UpdateDownloadReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
</intent-filter>
</receiver>
<meta-data
android:name="android.webkit.WebView.EnableSafeBrowsing"

View File

@@ -14,7 +14,6 @@ import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
@@ -25,8 +24,7 @@ import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.ui.util.reverseAsync
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
@@ -38,7 +36,6 @@ import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import javax.inject.Inject
@@ -61,11 +58,17 @@ class BookmarksFragment :
private var bookmarksAdapter: BookmarksAdapter? = null
private var selectionController: ListSelectionController? = null
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentListSimpleBinding {
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
): FragmentListSimpleBinding {
return FragmentListSimpleBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: FragmentListSimpleBinding, savedInstanceState: Bundle?) {
override fun onViewBindingCreated(
binding: FragmentListSimpleBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState)
selectionController = ListSelectionController(
activity = requireActivity(),
@@ -95,8 +98,11 @@ class BookmarksFragment :
viewModel.content.observe(viewLifecycleOwner) {
bookmarksAdapter?.setItems(it, spanSizeLookup)
}
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ::onActionDone)
viewModel.onError.observeEvent(
viewLifecycleOwner,
SnackbarErrorObserver(binding.recyclerView, this)
)
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
}
override fun onDestroyView() {
@@ -139,12 +145,20 @@ class BookmarksFragment :
requireViewBinding().recyclerView.invalidateItemDecorations()
}
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
override fun onCreateActionMode(
controller: ListSelectionController,
mode: ActionMode,
menu: Menu,
): Boolean {
mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
return true
}
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
override fun onActionItemClicked(
controller: ListSelectionController,
mode: ActionMode,
item: MenuItem,
): Boolean {
return when (item.itemId) {
R.id.action_remove -> {
val ids = selectionController?.snapshot() ?: return false
@@ -167,16 +181,6 @@ class BookmarksFragment :
}
}
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((activity as SnackbarOwner).snackbarHost, action.stringResId, length)
if (handle != null) {
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
}
snackbar.show()
}
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable {
init {
@@ -185,7 +189,8 @@ class BookmarksFragment :
}
override fun getSpanSize(position: Int): Int {
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount
?: return 1
return when (bookmarksAdapter?.getItemViewType(position)) {
ListItemType.PAGE_THUMB.ordinal -> 1
else -> total
@@ -200,6 +205,12 @@ class BookmarksFragment :
companion object {
@Deprecated(
"", ReplaceWith(
"BookmarksFragment()",
"org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment"
)
)
fun newInstance() = BookmarksFragment()
}
}

View File

@@ -9,7 +9,6 @@ import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
@@ -40,8 +39,4 @@ fun bookmarkListAD(
enqueueWith(coil)
}
}
onViewRecycled {
binding.imageViewThumb.disposeImageRequest()
}
}

View File

@@ -9,7 +9,6 @@ import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
@@ -42,8 +41,4 @@ fun bookmarkLargeAD(
}
binding.progressView.percent = item.percent
}
onViewRecycled {
binding.imageViewThumb.disposeImageRequest()
}
}

View File

@@ -162,7 +162,7 @@ class BookmarksSheet :
fun show(fm: FragmentManager, manga: Manga) {
BookmarksSheet().withArgs(1) {
putParcelable(ARG_MANGA, ParcelableManga(manga, withChapters = true))
putParcelable(ARG_MANGA, ParcelableManga(manga))
}.showDistinct(fm, TAG)
}
}

View File

@@ -35,6 +35,7 @@ class BookmarksSheetViewModel @Inject constructor(
val content: StateFlow<List<ListModel>> = bookmarksRepository.observeBookmarks(manga)
.map { mapList(it) }
.withErrorHandling()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingFooter()))
private suspend fun mapList(bookmarks: List<Bookmark>): List<ListModel> {

View File

@@ -1,28 +1,28 @@
package org.koitharu.kotatsu.browser.cloudflare
import android.annotation.SuppressLint
import android.content.Context
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.net.toUri
import coil.EventListener
import coil.request.ErrorResult
import coil.request.ImageRequest
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.parsers.model.ContentType
class CaptchaNotifier(
private val context: Context,
) : ImageRequest.Listener {
) : EventListener {
@SuppressLint("MissingPermission")
fun notify(exception: CloudFlareProtectedException) {
val manager = NotificationManagerCompat.from(context)
if (!manager.areNotificationsEnabled()) {
if (!context.checkNotificationPermission()) {
return
}
val manager = NotificationManagerCompat.from(context)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
.setName(context.getString(R.string.captcha_required))
.setShowBadge(true)

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.browser.cloudflare
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.webkit.CookieManager
import androidx.activity.result.contract.ActivityResultContract
@@ -11,8 +12,14 @@ import androidx.core.net.toUri
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.yield
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
@@ -38,7 +45,13 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
if (!catchingWebViewUnavailability {
setContentView(
ActivityBrowserBinding.inflate(
layoutInflater
)
)
}) {
return
}
supportActionBar?.run {
@@ -86,6 +99,11 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
viewBinding.webView.restoreState(savedInstanceState)
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.opt_captcha, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.appbar.updatePadding(
top = insets.top,
@@ -104,6 +122,19 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
true
}
R.id.action_retry -> {
lifecycleScope.launch {
viewBinding.webView.stopLoading()
yield()
val targetUrl = intent?.dataString?.toHttpUrlOrNull()
if (targetUrl != null) {
clearCfCookies(targetUrl)
viewBinding.webView.loadUrl(targetUrl.toString())
}
}
true
}
else -> super.onOptionsItemSelected(item)
}
@@ -141,7 +172,15 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
setTitle(title)
supportActionBar?.subtitle = subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
supportActionBar?.subtitle =
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
}
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
cookieJar.removeCookies(url) { cookie ->
val name = cookie.name
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf")
}
}
class Contract : ActivityResultContract<Pair<String, Headers?>, TaggedActivityResult>() {

View File

@@ -13,7 +13,6 @@ import coil.decode.SvgDecoder
import coil.disk.DiskCache
import coil.util.DebugLogger
import dagger.Binds
import dagger.Lazy
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -26,11 +25,13 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.StubContentCache
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.network.*
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
@@ -40,14 +41,13 @@ import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.util.AcraScreenLogger
import org.koitharu.kotatsu.core.util.IncognitoModeIndicator
import org.koitharu.kotatsu.core.util.ext.activityManager
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.main.domain.CoverRestorer
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.ui.thumbnails.MangaPageFetcher
@@ -91,7 +91,7 @@ interface AppModule {
mangaRepositoryFactory: MangaRepository.Factory,
imageProxyInterceptor: ImageProxyInterceptor,
pageFetcherFactory: MangaPageFetcher.Factory,
coverRestorerProvider: Lazy<CoverRestorer>,
coverRestoreInterceptor: CoverRestoreInterceptor,
): ImageLoader {
val diskCacheFactory = {
val rootDir = context.externalCacheDir ?: context.cacheDir
@@ -108,7 +108,7 @@ interface AppModule {
.diskCache(diskCacheFactory)
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
.allowRgb565(context.isLowRamDevice())
.eventListenerFactory { coverRestorerProvider.get() }
.eventListener(CaptchaNotifier(context))
.components(
ComponentRegistry.Builder()
.add(SvgDecoder.Factory())
@@ -116,6 +116,7 @@ interface AppModule {
.add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory))
.add(pageFetcherFactory)
.add(imageProxyInterceptor)
.add(coverRestoreInterceptor)
.build(),
).build()
}
@@ -160,7 +161,7 @@ interface AppModule {
fun provideContentCache(
application: Application,
): ContentCache {
return if (application.activityManager?.isLowRamDevice == true) {
return if (application.isLowRamDevice()) {
StubContentCache()
} else {
MemoryContentCache(application)

View File

@@ -20,25 +20,8 @@ interface ContentCache {
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>)
class Key(
data class Key(
val source: MangaSource,
val url: String,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Key
if (source != other.source) return false
return url == other.url
}
override fun hashCode(): Int {
var result = source.hashCode()
result = 31 * result + url.hashCode()
return result
}
}
)
}

View File

@@ -6,6 +6,7 @@ import androidx.room.InvalidationTracker
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -118,7 +119,7 @@ fun MangaDatabase(context: Context): MangaDatabase = Room
fun InvalidationTracker.removeObserverAsync(observer: InvalidationTracker.Observer) {
val scope = processLifecycleScope
if (scope.isActive) {
processLifecycleScope.launch(Dispatchers.Default) {
processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) {
removeObserver(observer)
}
}

View File

@@ -6,3 +6,4 @@ const val TABLE_TAGS = "tags"
const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories"
const val TABLE_HISTORY = "history"
const val TABLE_MANGA_TAGS = "manga_tags"
const val TABLE_SOURCES = "sources"

View File

@@ -51,6 +51,28 @@ abstract class TagsDao {
)
abstract suspend fun findTags(query: String, limit: Int): List<TagEntity>
@Query(
"""
SELECT tags.* FROM manga_tags
LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id
WHERE manga_tags.manga_id IN (SELECT manga_id FROM manga_tags WHERE tag_id = :tagId)
GROUP BY tags.tag_id
ORDER BY COUNT(manga_id) DESC;
""",
)
abstract suspend fun findRelatedTags(tagId: Long): List<TagEntity>
@Query(
"""
SELECT tags.* FROM manga_tags
LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id
WHERE manga_tags.manga_id IN (SELECT manga_id FROM manga_tags WHERE tag_id IN (:ids))
GROUP BY tags.tag_id
ORDER BY COUNT(manga_id) DESC;
""",
)
abstract suspend fun findRelatedTags(ids: Set<Long>): List<TagEntity>
@Upsert
abstract suspend fun upsert(tags: Iterable<TagEntity>)
}

View File

@@ -19,6 +19,8 @@ fun TagEntity.toMangaTag() = MangaTag(
fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
fun Collection<TagEntity>.toMangaTagsList() = map(TagEntity::toMangaTag)
fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
id = this.id,
title = this.title,

View File

@@ -3,9 +3,10 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.TABLE_SOURCES
@Entity(
tableName = "sources",
tableName = TABLE_SOURCES,
)
data class MangaSourceEntity(
@PrimaryKey(autoGenerate = false)

View File

@@ -4,7 +4,7 @@ import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
class MangaWithTags(
data class MangaWithTags(
@Embedded val manga: MangaEntity,
@Relation(
parentColumn = "manga_id",
@@ -12,21 +12,4 @@ class MangaWithTags(
associateBy = Junction(MangaTagsEntity::class)
)
val tags: List<TagEntity>,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MangaWithTags
if (manga != other.manga) return false
return tags == other.tags
}
override fun hashCode(): Int {
var result = manga.hashCode()
result = 31 * result + tags.hashCode()
return result
}
}
)

View File

@@ -6,4 +6,8 @@ import java.util.Date
class TooManyRequestExceptions(
val url: String,
val retryAt: Date?,
) : IOException()
) : IOException() {
val retryAfter: Long
get() = if (retryAt == null) 0 else (retryAt.time - System.currentTimeMillis()).coerceAtLeast(0)
}

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.github
import java.util.*
class VersionId(
data class VersionId(
val major: Int,
val minor: Int,
val build: Int,
@@ -30,28 +30,6 @@ class VersionId(
return variantNumber.compareTo(other.variantNumber)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as VersionId
if (major != other.major) return false
if (minor != other.minor) return false
if (build != other.build) return false
if (variantType != other.variantType) return false
return variantNumber == other.variantNumber
}
override fun hashCode(): Int {
var result = major
result = 31 * result + minor
result = 31 * result + build
result = 31 * result + variantType.hashCode()
result = 31 * result + variantNumber
return result
}
private fun variantWeight(variantType: String) = when (variantType.lowercase(Locale.ROOT)) {
"a", "alpha" -> 1
"b", "beta" -> 2

View File

@@ -2,9 +2,9 @@ package org.koitharu.kotatsu.core.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.Date
@Parcelize
@@ -12,7 +12,7 @@ data class FavouriteCategory(
val id: Long,
val title: String,
val sortKey: Int,
val order: SortOrder,
val order: ListSortOrder,
val createdAt: Date,
val isTrackingEnabled: Boolean,
val isVisibleInLibrary: Boolean,

View File

@@ -17,6 +17,8 @@ fun Collection<Manga>.distinctById() = distinctBy { it.id }
@JvmName("chaptersIds")
fun Collection<MangaChapter>.ids() = mapToSet { it.id }
fun Collection<MangaChapter>.findById(id: Long) = find { x -> x.id == id }
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) {
return size
@@ -30,7 +32,7 @@ fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
}
fun Manga.findChapter(id: Long): MangaChapter? {
return chapters?.find { it.id == id }
return chapters?.findById(id)
}
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
@@ -39,7 +41,7 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
return null
}
if (history != null) {
val currentChapter = ch.find { it.id == history.chapterId }
val currentChapter = ch.findById(history.chapterId)
if (currentChapter != null) {
return currentChapter.branch
}
@@ -48,10 +50,10 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
if (groups.size == 1) {
return groups.keys.first()
}
val candidates = HashMap<String?, List<MangaChapter>>(groups.size)
for (locale in LocaleListCompat.getAdjustedDefault()) {
val displayLanguage = locale.getDisplayLanguage(locale)
val displayName = locale.getDisplayName(locale)
val candidates = HashMap<String?, List<MangaChapter>>(3)
for (branch in groups.keys) {
if (branch != null && (
branch.contains(displayLanguage, ignoreCase = true) ||
@@ -61,8 +63,11 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
candidates[branch] = groups[branch] ?: continue
}
}
if (candidates.isNotEmpty()) {
return candidates.maxBy { it.value.size }.key
}
}
return candidates.ifEmpty { groups }.maxByOrNull { it.value.size }?.key
return groups.maxByOrNull { it.value.size }?.key
}
val Manga.isLocal: Boolean

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.model
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale
@@ -15,3 +16,5 @@ fun MangaSource(name: String): MangaSource {
}
return MangaSource.DUMMY
}
fun MangaSource.isNsfw() = contentType == ContentType.HENTAI

View File

@@ -0,0 +1,42 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
@Parcelize
data class ParcelableChapter(
val chapter: MangaChapter,
) : Parcelable {
companion object : Parceler<ParcelableChapter> {
override fun create(parcel: Parcel) = ParcelableChapter(
MangaChapter(
id = parcel.readLong(),
name = parcel.readString().orEmpty(),
number = parcel.readInt(),
url = parcel.readString().orEmpty(),
scanlator = parcel.readString(),
uploadDate = parcel.readLong(),
branch = parcel.readString(),
source = parcel.readSerializableCompat() ?: MangaSource.DUMMY,
)
)
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
parcel.writeLong(id)
parcel.writeString(name)
parcel.writeInt(number)
parcel.writeString(url)
parcel.writeString(scanlator)
parcel.writeLong(uploadDate)
parcel.writeString(branch)
parcel.writeSerializable(source)
}
}
}

View File

@@ -9,55 +9,28 @@ import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.Manga
// Limits to avoid TransactionTooLargeException
private const val MAX_SAFE_SIZE = 1024 * 100 // Assume that 100 kb is safe parcel size
private const val MAX_SAFE_CHAPTERS_COUNT = 24 // this is 100% safe
@Parcelize
data class ParcelableManga(
val manga: Manga,
private val withChapters: Boolean,
) : Parcelable {
companion object : Parceler<ParcelableManga> {
private fun Manga.writeToParcel(out: Parcel, flags: Int, withChapters: Boolean) {
out.writeLong(id)
out.writeString(title)
out.writeString(altTitle)
out.writeString(url)
out.writeString(publicUrl)
out.writeFloat(rating)
ParcelCompat.writeBoolean(out, isNsfw)
out.writeString(coverUrl)
out.writeString(largeCoverUrl)
out.writeString(description)
out.writeParcelable(ParcelableMangaTags(tags), flags)
out.writeSerializable(state)
out.writeString(author)
val parcelableChapters = if (withChapters) null else chapters?.let(::ParcelableMangaChapters)
out.writeParcelable(parcelableChapters, flags)
out.writeSerializable(source)
}
override fun ParcelableManga.write(parcel: Parcel, flags: Int) {
val chapters = manga.chapters
if (!withChapters || chapters == null) {
manga.writeToParcel(parcel, flags, withChapters = false)
return
}
if (chapters.size <= MAX_SAFE_CHAPTERS_COUNT) {
// fast path
manga.writeToParcel(parcel, flags, withChapters = true)
return
}
val tempParcel = Parcel.obtain()
manga.writeToParcel(tempParcel, flags, withChapters = true)
val size = tempParcel.dataSize()
if (size < MAX_SAFE_SIZE) {
parcel.appendFrom(tempParcel, 0, size)
} else {
manga.writeToParcel(parcel, flags, withChapters = false)
}
tempParcel.recycle()
companion object : Parceler<ParcelableManga> {
override fun ParcelableManga.write(parcel: Parcel, flags: Int) = with(manga) {
parcel.writeLong(id)
parcel.writeString(title)
parcel.writeString(altTitle)
parcel.writeString(url)
parcel.writeString(publicUrl)
parcel.writeFloat(rating)
ParcelCompat.writeBoolean(parcel, isNsfw)
parcel.writeString(coverUrl)
parcel.writeString(largeCoverUrl)
parcel.writeString(description)
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
parcel.writeSerializable(state)
parcel.writeString(author)
parcel.writeSerializable(source)
}
override fun create(parcel: Parcel) = ParcelableManga(
@@ -75,10 +48,9 @@ data class ParcelableManga(
tags = requireNotNull(parcel.readParcelableCompat<ParcelableMangaTags>()).tags,
state = parcel.readSerializableCompat(),
author = parcel.readString(),
chapters = parcel.readParcelableCompat<ParcelableMangaChapters>()?.chapters,
chapters = null,
source = requireNotNull(parcel.readSerializableCompat()),
),
withChapters = true
)
)
}
}

View File

@@ -1,37 +0,0 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.MangaChapter
object MangaChapterParceler : Parceler<MangaChapter> {
override fun create(parcel: Parcel) = MangaChapter(
id = parcel.readLong(),
name = requireNotNull(parcel.readString()),
number = parcel.readInt(),
url = requireNotNull(parcel.readString()),
scanlator = parcel.readString(),
uploadDate = parcel.readLong(),
branch = parcel.readString(),
source = requireNotNull(parcel.readSerializableCompat()),
)
override fun MangaChapter.write(parcel: Parcel, flags: Int) {
parcel.writeLong(id)
parcel.writeString(name)
parcel.writeInt(number)
parcel.writeString(url)
parcel.writeString(scanlator)
parcel.writeLong(uploadDate)
parcel.writeString(branch)
parcel.writeSerializable(source)
}
}
@Parcelize
@TypeParceler<MangaChapter, MangaChapterParceler>
data class ParcelableMangaChapters(val chapters: List<MangaChapter>) : Parcelable

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.network
import okhttp3.Interceptor
import okhttp3.Response
import okio.IOException
import org.koitharu.kotatsu.core.network.CommonHeaders.CONTENT_ENCODING
class GZipInterceptor : Interceptor {
@@ -9,6 +10,10 @@ class GZipInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val newRequest = chain.request().newBuilder()
newRequest.addHeader(CONTENT_ENCODING, "gzip")
return chain.proceed(newRequest.build())
return try {
chain.proceed(newRequest.build())
} catch (e: NullPointerException) {
throw IOException(e)
}
}
}

View File

@@ -1,6 +1,9 @@
package org.koitharu.kotatsu.core.network
import androidx.collection.ArraySet
import dagger.Lazy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
@@ -13,6 +16,7 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.EnumMap
import javax.inject.Inject
import javax.inject.Singleton
@@ -22,9 +26,15 @@ class MirrorSwitchInterceptor @Inject constructor(
private val settings: AppSettings,
) : Interceptor {
private val locks = EnumMap<MangaSource, Any>(MangaSource::class.java)
private val blacklist = EnumMap<MangaSource, MutableSet<String>>(MangaSource::class.java)
val isEnabled: Boolean
get() = settings.isMirrorSwitchingAvailable
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (!settings.isMirrorSwitchingAvailable) {
if (!isEnabled) {
return chain.proceed(request)
}
return try {
@@ -43,6 +53,30 @@ class MirrorSwitchInterceptor @Inject constructor(
}
}
suspend fun trySwitchMirror(repository: RemoteMangaRepository): Boolean = runInterruptible(Dispatchers.Default) {
if (!isEnabled) {
return@runInterruptible false
}
val mirrors = repository.getAvailableMirrors()
if (mirrors.size <= 1) {
return@runInterruptible false
}
synchronized(obtainLock(repository.source)) {
val currentMirror = repository.domain
addToBlacklist(repository.source, currentMirror)
val newMirror = mirrors.firstOrNull { x ->
x != currentMirror && !isBlacklisted(repository.source, x)
} ?: return@synchronized false
repository.domain = newMirror
true
}
}
fun rollback(repository: RemoteMangaRepository, oldMirror: String) = synchronized(obtainLock(repository.source)) {
blacklist[repository.source]?.remove(oldMirror)
repository.domain = oldMirror
}
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
@@ -50,7 +84,9 @@ class MirrorSwitchInterceptor @Inject constructor(
if (mirrors.isEmpty()) {
return null
}
return tryMirrors(repository, mirrors, chain, request)
return synchronized(obtainLock(repository.source)) {
tryMirrors(repository, mirrors, chain, request)
}
}
private fun tryMirrors(
@@ -66,7 +102,7 @@ class MirrorSwitchInterceptor @Inject constructor(
}
val urlBuilder = url.newBuilder()
for (mirror in mirrors) {
if (mirror == currentDomain) {
if (mirror == currentDomain || isBlacklisted(repository.source, mirror)) {
continue
}
val newHost = hostOf(url.host, mirror) ?: continue
@@ -75,6 +111,7 @@ class MirrorSwitchInterceptor @Inject constructor(
.build()
val response = chain.proceed(newRequest)
if (response.isFailed) {
addToBlacklist(repository.source, mirror)
response.closeQuietly()
} else {
repository.domain = mirror
@@ -104,4 +141,18 @@ class MirrorSwitchInterceptor @Inject constructor(
private fun ResponseBody.copy(): ResponseBody {
return source().readByteArray().toResponseBody(contentType())
}
private fun obtainLock(source: MangaSource): Any = locks.getOrPut(source) {
Any()
}
private fun isBlacklisted(source: MangaSource, domain: String): Boolean {
return blacklist[source]?.contains(domain) == true
}
private fun addToBlacklist(source: MangaSource, domain: String) {
blacklist.getOrPut(source) {
ArraySet(2)
}.add(domain)
}
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.network.cookies
import android.webkit.CookieManager
import androidx.annotation.WorkerThread
import androidx.core.util.Predicate
import okhttp3.Cookie
import okhttp3.HttpUrl
import org.koitharu.kotatsu.core.util.ext.newBuilder
@@ -31,19 +32,21 @@ class AndroidCookieJar : MutableCookieJar {
}
}
override fun removeCookies(url: HttpUrl) {
override fun removeCookies(url: HttpUrl, predicate: Predicate<Cookie>?) {
val cookies = loadForRequest(url)
if (cookies.isEmpty()) {
return
}
val urlString = url.toString()
for (c in cookies) {
if (predicate != null && !predicate.test(c)) {
continue
}
val nc = c.newBuilder()
.expiresAt(System.currentTimeMillis() - 100000)
.build()
cookieManager.setCookie(urlString, nc.toString())
}
check(loadForRequest(url).isEmpty())
}
override suspend fun clear() = suspendCoroutine<Boolean> { continuation ->

View File

@@ -8,7 +8,7 @@ import java.io.ObjectInputStream
import java.io.ObjectOutputStream
class CookieWrapper(
data class CookieWrapper(
val cookie: Cookie,
) {
@@ -66,17 +66,4 @@ class CookieWrapper(
fun key(): String {
return (if (cookie.secure) "https" else "http") + "://" + cookie.domain + cookie.path + "|" + cookie.name
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CookieWrapper
return cookie == other.cookie
}
override fun hashCode(): Int {
return cookie.hashCode()
}
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.network.cookies
import androidx.annotation.WorkerThread
import androidx.core.util.Predicate
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
@@ -14,7 +15,7 @@ interface MutableCookieJar : CookieJar {
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>)
@WorkerThread
fun removeCookies(url: HttpUrl)
fun removeCookies(url: HttpUrl, predicate: Predicate<Cookie>?)
suspend fun clear(): Boolean
}

View File

@@ -4,6 +4,7 @@ import android.content.Context
import androidx.annotation.WorkerThread
import androidx.collection.ArrayMap
import androidx.core.content.edit
import androidx.core.util.Predicate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Cookie
@@ -57,12 +58,14 @@ class PreferencesCookieJar(
@Synchronized
@WorkerThread
override fun removeCookies(url: HttpUrl) {
override fun removeCookies(url: HttpUrl, predicate: Predicate<Cookie>?) {
loadPersistent()
val toRemove = HashSet<String>()
for ((key, cookie) in cache) {
if (cookie.isExpired() || cookie.cookie.matches(url)) {
toRemove += key
if (predicate == null || predicate.test(cookie.cookie)) {
toRemove += key
}
}
}
if (toRemove.isNotEmpty()) {

View File

@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
@@ -29,8 +30,10 @@ import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.search.ui.MangaListActivity
import javax.inject.Inject
import javax.inject.Singleton
@@ -73,8 +76,18 @@ class AppShortcutManager @Inject constructor(
}
}
suspend fun requestPinShortcut(manga: Manga): Boolean {
return ShortcutManagerCompat.requestPinShortcut(context, buildShortcutInfo(manga), null)
suspend fun requestPinShortcut(manga: Manga): Boolean = try {
ShortcutManagerCompat.requestPinShortcut(context, buildShortcutInfo(manga), null)
} catch (e: IllegalStateException) {
e.printStackTraceDebug()
false
}
suspend fun requestPinShortcut(source: MangaSource): Boolean = try {
ShortcutManagerCompat.requestPinShortcut(context, buildShortcutInfo(source), null)
} catch (e: IllegalStateException) {
e.printStackTraceDebug()
false
}
@VisibleForTesting
@@ -86,6 +99,11 @@ class AppShortcutManager @Inject constructor(
ShortcutManagerCompat.reportShortcutUsed(context, mangaId.toString())
}
fun isDynamicShortcutsAvailable(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 &&
context.getSystemService(ShortcutManager::class.java).maxShortcutCountPerActivity > 0
}
private suspend fun updateShortcutsImpl() = runCatchingCancellable {
val maxShortcuts = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context).coerceAtLeast(5)
val shortcuts = historyRepository.getList(0, maxShortcuts)
@@ -132,8 +150,25 @@ class AppShortcutManager @Inject constructor(
.build()
}
fun isDynamicShortcutsAvailable(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 &&
context.getSystemService(ShortcutManager::class.java).maxShortcutCountPerActivity > 0
private suspend fun buildShortcutInfo(source: MangaSource): ShortcutInfoCompat {
val icon = runCatchingCancellable {
coil.execute(
ImageRequest.Builder(context)
.data(source.faviconUri())
.size(iconSize)
.scale(Scale.FIT)
.build(),
).getDrawableOrThrow().toBitmap()
}.fold(
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
)
return ShortcutInfoCompat.Builder(context, source.name)
.setShortLabel(source.title)
.setLongLabel(source.title)
.setIcon(icon)
.setLongLived(true)
.setIntent(MangaListActivity.newIntent(context, source))
.build()
}
}

View File

@@ -43,5 +43,7 @@ class MangaIntent private constructor(
const val KEY_MANGA = "manga"
const val KEY_ID = "id"
fun of(manga: Manga) = MangaIntent(manga, manga.id, null)
}
}

View File

@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.lang.ref.WeakReference
import java.util.*
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
@@ -50,7 +50,7 @@ class MangaLoaderContextImpl @Inject constructor(
}
override fun encodeBase64(data: ByteArray): String {
return Base64.encodeToString(data, Base64.NO_PADDING)
return Base64.encodeToString(data, Base64.NO_WRAP)
}
override fun decodeBase64(data: String): ByteArray {

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.parser
import androidx.annotation.AnyThread
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.Manga
@@ -43,6 +44,7 @@ interface MangaRepository {
private val localMangaRepository: LocalMangaRepository,
private val loaderContext: MangaLoaderContext,
private val contentCache: ContentCache,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
) {
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
@@ -55,7 +57,11 @@ interface MangaRepository {
cache[source]?.get()?.let { return it }
return synchronized(cache) {
cache[source]?.get()?.let { return it }
val repository = RemoteMangaRepository(MangaParser(source, loaderContext), contentCache)
val repository = RemoteMangaRepository(
parser = MangaParser(source, loaderContext),
cache = contentCache,
mirrorSwitchInterceptor = mirrorSwitchInterceptor,
)
cache[source] = WeakReference(repository)
repository
}

View File

@@ -13,11 +13,13 @@ import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.cache.SafeDeferred
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.Favicons
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
@@ -31,6 +33,7 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
class RemoteMangaRepository(
private val parser: MangaParser,
private val cache: ContentCache,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
) : MangaRepository, Interceptor {
override val source: MangaSource
@@ -66,11 +69,15 @@ class RemoteMangaRepository(
}
override suspend fun getList(offset: Int, query: String): List<Manga> {
return parser.getList(offset, query)
return mirrorSwitchInterceptor.withMirrorSwitching {
parser.getList(offset, query)
}
}
override suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> {
return parser.getList(offset, tags, sortOrder)
return mirrorSwitchInterceptor.withMirrorSwitching {
parser.getList(offset, tags, sortOrder)
}
}
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, withCache = true)
@@ -78,17 +85,25 @@ class RemoteMangaRepository(
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
cache.getPages(source, chapter.url)?.let { return it }
val pages = asyncSafe {
parser.getPages(chapter).distinctById()
mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPages(chapter).distinctById()
}
}
cache.putPages(source, chapter.url, pages)
return pages.await()
}
override suspend fun getPageUrl(page: MangaPage): String = parser.getPageUrl(page)
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPageUrl(page)
}
override suspend fun getTags(): Set<MangaTag> = parser.getTags()
override suspend fun getTags(): Set<MangaTag> = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getTags()
}
suspend fun getFavicons(): Favicons = parser.getFavicons()
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getFavicons()
}
override suspend fun getRelated(seed: Manga): List<Manga> {
cache.getRelatedManga(source, seed.url)?.let { return it }
@@ -105,12 +120,18 @@ class RemoteMangaRepository(
}
cache.getDetails(source, manga.url)?.let { return it }
val details = asyncSafe {
parser.getDetails(manga)
mirrorSwitchInterceptor.withMirrorSwitching {
parser.getDetails(manga)
}
}
cache.putDetails(source, manga.url, details)
return details.await()
}
suspend fun peekDetails(manga: Manga): Manga? {
return cache.getDetails(source, manga.url)
}
suspend fun find(manga: Manga): Manga? {
val list = getList(0, manga.title)
return list.find { x -> x.id == manga.id }
@@ -155,4 +176,33 @@ class RemoteMangaRepository(
}
return result
}
private suspend fun <R> MirrorSwitchInterceptor.withMirrorSwitching(block: suspend () -> R): R {
if (!isEnabled) {
return block()
}
val initialMirror = domain
val result = runCatchingCancellable {
block()
}
if (result.isValidResult()) {
return result.getOrThrow()
}
return if (trySwitchMirror(this@RemoteMangaRepository)) {
val newResult = runCatchingCancellable {
block()
}
if (newResult.isValidResult()) {
return newResult.getOrThrow()
} else {
rollback(this@RemoteMangaRepository, initialMirror)
return result.getOrThrow()
}
} else {
result.getOrThrow()
}
}
private fun Result<*>.isValidResult() = exceptionOrNull() !is ParseException
&& (getOrNull() as? Collection<*>)?.isEmpty() != true
}

View File

@@ -14,6 +14,7 @@ import coil.network.HttpException
import coil.request.Options
import coil.size.Size
import coil.size.pxOrElse
import kotlinx.coroutines.ensureActive
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -25,11 +26,13 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import java.net.HttpURLConnection
import kotlin.coroutines.coroutineContext
private const val FALLBACK_SIZE = 9999 // largest icon
@@ -55,13 +58,16 @@ class FaviconFetcher(
options.size.height.pxOrElse { FALLBACK_SIZE },
)
var favicons = repo.getFavicons()
var lastError: Exception? = null
while (favicons.isNotEmpty()) {
val icon = favicons.find(sizePx) ?: throwNSEE()
coroutineContext.ensureActive()
val icon = favicons.find(sizePx) ?: throwNSEE(lastError)
val response = try {
loadIcon(icon.url, mangaSource)
} catch (e: CloudFlareProtectedException) {
throw e
} catch (e: HttpException) {
lastError = e
favicons -= icon
continue
}
@@ -75,7 +81,7 @@ class FaviconFetcher(
dataSource = response.toDataSource(),
)
}
throwNSEE()
throwNSEE(lastError)
}
private suspend fun loadIcon(url: String, source: MangaSource): Response {
@@ -105,14 +111,14 @@ class FaviconFetcher(
)
}
private fun writeToDiskCache(body: ResponseBody): DiskCache.Snapshot? {
private suspend fun writeToDiskCache(body: ResponseBody): DiskCache.Snapshot? {
if (!options.diskCachePolicy.writeEnabled || body.contentLength() == 0L) {
return null
}
val editor = diskCache.value?.openEditor(diskCacheKey) ?: return null
try {
fileSystem.write(editor.data) {
body.source().readAll(this)
writeAllCancellable(body.source())
}
return editor.commitAndOpenSnapshot()
} catch (e: Throwable) {
@@ -154,7 +160,13 @@ class FaviconFetcher(
append(height.toString())
}
private fun throwNSEE(): Nothing = throw NoSuchElementException("No favicons found")
private fun throwNSEE(lastError: Exception?): Nothing {
if (lastError != null) {
throw lastError
} else {
throw NoSuchElementException("No favicons found")
}
}
class Factory(
context: Context,

View File

@@ -22,8 +22,9 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.history.domain.model.HistoryOrder
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet
import java.io.File
@@ -43,7 +44,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
val theme: Int
get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
get() = prefs.getString(KEY_THEME, null)?.toIntOrNull()
?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
val colorScheme: ColorScheme
get() = prefs.getEnumValue(KEY_COLOR_THEME, ColorScheme.default)
@@ -51,13 +53,37 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isAmoledTheme: Boolean
get() = prefs.getBoolean(KEY_THEME_AMOLED, false)
val isFavoritesNavItemFirst: Boolean
get() = (prefs.getString(KEY_FIRST_NAV_ITEM, null)?.toIntOrNull() ?: 0) == 1
var mainNavItems: List<NavItem>
get() {
val raw = prefs.getString(KEY_NAV_MAIN, null)?.split(',')
return if (raw.isNullOrEmpty()) {
listOf(NavItem.HISTORY, NavItem.FAVORITES, NavItem.EXPLORE, NavItem.FEED)
} else {
raw.mapNotNull { x -> NavItem.entries.find(x) }.ifEmpty { listOf(NavItem.EXPLORE) }
}
}
set(value) {
prefs.edit {
putString(KEY_NAV_MAIN, value.joinToString(",") { it.name })
}
}
var gridSize: Int
get() = prefs.getInt(KEY_GRID_SIZE, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
var historyListMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE_HISTORY, listMode)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_HISTORY, value) }
var suggestionsListMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE_SUGGESTIONS, listMode)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_SUGGESTIONS, value) }
var favoritesListMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE_FAVORITES, listMode)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_FAVORITES, value) }
var isNsfwContentDisabled: Boolean
get() = prefs.getBoolean(KEY_DISABLE_NSFW, false)
set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) }
@@ -76,6 +102,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val readerPageSwitch: Set<String>
get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS)
val isReaderZoomButtonsEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_ZOOM_BUTTONS, false)
val isReaderTapsAdaptive: Boolean
get() = !prefs.getBoolean(KEY_READER_TAPS_LTR, false)
@@ -145,7 +174,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
var appPassword: String?
get() = prefs.getString(KEY_APP_PASSWORD, null)
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
set(value) = prefs.edit {
if (value != null) putString(KEY_APP_PASSWORD, value) else remove(
KEY_APP_PASSWORD,
)
}
val isLoggingEnabled: Boolean
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
@@ -155,7 +188,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
val isMirrorSwitchingAvailable: Boolean
get() = prefs.getBoolean(KEY_MIRROR_SWITCHING, true)
get() = prefs.getBoolean(KEY_MIRROR_SWITCHING, false)
val isExitConfirmationEnabled: Boolean
get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false)
@@ -171,7 +204,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
if (isBackgroundNetworkRestricted()) {
return false
}
val policy = NetworkPolicy.from(prefs.getString(KEY_PREFETCH_CONTENT, null), NetworkPolicy.NEVER)
val policy =
NetworkPolicy.from(prefs.getString(KEY_PREFETCH_CONTENT, null), NetworkPolicy.NEVER)
return policy.isNetworkAllowed(connectivityManager)
}
@@ -179,6 +213,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_SOURCES_GRID, false)
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
val isNewSourcesTipEnabled: Boolean
get() = prefs.getBoolean(KEY_SOURCES_NEW, true)
val isPagesNumbersEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
@@ -248,6 +285,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderSliderEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_SLIDER, true)
val isReaderKeepScreenOn: Boolean
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
val isImagesProxyEnabled: Boolean
get() = prefs.getBoolean(KEY_IMAGES_PROXY, false)
@@ -279,8 +319,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST)
set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) }
var historySortOrder: HistoryOrder
get() = prefs.getEnumValue(KEY_HISTORY_ORDER, HistoryOrder.UPDATED)
var historySortOrder: ListSortOrder
get() = prefs.getEnumValue(KEY_HISTORY_ORDER, ListSortOrder.UPDATED)
set(value) = prefs.edit { putEnumValue(KEY_HISTORY_ORDER, value) }
val isRelatedMangaEnabled: Boolean
@@ -292,17 +332,28 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
@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) }
set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit {
putFloat(
KEY_READER_AUTOSCROLL_SPEED,
value,
)
}
val isPagesPreloadEnabled: Boolean
get() {
if (isBackgroundNetworkRestricted()) {
return false
}
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)
}
val is32BitColorsEnabled: Boolean
get() = prefs.getBoolean(KEY_32BIT_COLOR, false)
fun isTipEnabled(tip: String): Boolean {
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
}
@@ -368,6 +419,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val TRACK_FAVOURITES = "favourites"
const val KEY_LIST_MODE = "list_mode_2"
const val KEY_LIST_MODE_HISTORY = "list_mode_history"
const val KEY_LIST_MODE_FAVORITES = "list_mode_favorites"
const val KEY_LIST_MODE_SUGGESTIONS = "list_mode_suggestions"
const val KEY_THEME = "theme"
const val KEY_COLOR_THEME = "color_theme"
const val KEY_THEME_AMOLED = "amoled_theme"
@@ -382,6 +436,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_REMOTE_SOURCES = "remote_sources"
const val KEY_LOCAL_STORAGE = "local_storage"
const val KEY_READER_SWITCHERS = "reader_switchers"
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
const val KEY_TRACKER_ENABLED = "tracker_enabled"
const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi"
const val KEY_TRACK_SOURCES = "track_sources"
@@ -429,6 +484,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_BAR = "reader_bar"
const val KEY_READER_SLIDER = "reader_slider"
const val KEY_READER_BACKGROUND = "reader_background"
const val KEY_READER_SCREEN_ON = "reader_screen_on"
const val KEY_SHORTCUTS = "dynamic_shortcuts"
const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
const val KEY_LOCAL_LIST_ORDER = "local_order"
@@ -439,6 +495,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LOGGING_ENABLED = "logging"
const val KEY_LOGS_SHARE = "logs_share"
const val KEY_SOURCES_GRID = "sources_grid"
const val KEY_SOURCES_NEW = "sources_new"
const val KEY_UPDATES_UNSTABLE = "updates_unstable"
const val KEY_TIPS_CLOSED = "tips_closed"
const val KEY_SSL_BYPASS = "ssl_bypass"
@@ -455,7 +512,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
const val KEY_DISABLE_NSFW = "no_nsfw"
const val KEY_RELATED_MANGA = "related_manga"
const val KEY_FIRST_NAV_ITEM = "nav_first"
const val KEY_NAV_MAIN = "nav_main"
const val KEY_32BIT_COLOR = "enhanced_colors"
// About
const val KEY_APP_UPDATE = "app_update"

View File

@@ -1,15 +1,38 @@
package org.koitharu.kotatsu.core.prefs
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.os.Build
import androidx.core.content.edit
private const val CATEGORY_ID = "cat_id"
private const val BACKGROUND = "bg"
class AppWidgetConfig(context: Context, val widgetId: Int) {
class AppWidgetConfig(
context: Context,
cls: Class<out AppWidgetProvider>,
val widgetId: Int,
) {
private val prefs = context.getSharedPreferences("appwidget_$widgetId", Context.MODE_PRIVATE)
private val prefs = context.getSharedPreferences("appwidget_${cls.simpleName}_$widgetId", Context.MODE_PRIVATE)
var categoryId: Long
get() = prefs.getLong(CATEGORY_ID, 0L)
set(value) = prefs.edit { putLong(CATEGORY_ID, value) }
var hasBackground: Boolean
get() = prefs.getBoolean(BACKGROUND, Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
set(value) = prefs.edit { putBoolean(BACKGROUND, value) }
fun clear() {
prefs.edit { clear() }
}
fun copyFrom(other: AppWidgetConfig) {
prefs.edit {
clear()
putLong(CATEGORY_ID, other.categoryId)
putBoolean(BACKGROUND, other.hasBackground)
}
}
}

View File

@@ -0,0 +1,33 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.DrawableRes
import androidx.annotation.IdRes
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.model.ListModel
enum class NavItem(
@IdRes val id: Int,
@StringRes val title: Int,
@DrawableRes val icon: Int,
) : ListModel {
HISTORY(R.id.nav_history, R.string.history, R.drawable.ic_history_selector),
FAVORITES(R.id.nav_favorites, R.string.favourites, R.drawable.ic_favourites_selector),
LOCAL(R.id.nav_local, R.string.on_device, R.drawable.ic_storage_selector),
EXPLORE(R.id.nav_explore, R.string.explore, R.drawable.ic_explore_selector),
SUGGESTIONS(R.id.nav_suggestions, R.string.suggestions, R.drawable.ic_suggestion_selector),
FEED(R.id.nav_feed, R.string.feed, R.drawable.ic_feed_selector),
BOOKMARKS(R.id.nav_bookmarks, R.string.bookmarks, R.drawable.ic_bookmark_selector),
;
override fun areItemsTheSame(other: ListModel): Boolean {
return other is NavItem && ordinal == other.ordinal
}
fun isAvailable(settings: AppSettings): Boolean = when (this) {
SUGGESTIONS -> settings.isSuggestionsEnabled
FEED -> settings.isTrackerEnabled
else -> true
}
}

View File

@@ -3,8 +3,8 @@ package org.koitharu.kotatsu.core.prefs
enum class ReaderMode(val id: Int) {
STANDARD(1),
WEBTOON(2),
REVERSED(3);
REVERSED(3),
WEBTOON(2);
companion object {

View File

@@ -126,10 +126,10 @@ abstract class BaseActivity<B : ViewBinding> :
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ColorUtils.compositeColors(
ContextCompat.getColor(this, com.google.android.material.R.color.m3_appbar_overlay_color),
getThemeColor(com.google.android.material.R.attr.colorSurface),
getThemeColor(R.attr.m3ColorBackground),
)
} else {
ContextCompat.getColor(this, R.color.kotatsu_secondaryContainer)
ContextCompat.getColor(this, R.color.kotatsu_m3_background)
}
val insets = ViewCompat.getRootWindowInsets(viewBinding.root)
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return

View File

@@ -0,0 +1,51 @@
package org.koitharu.kotatsu.core.ui
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.widget.RemoteViews
import androidx.annotation.CallSuper
import org.koitharu.kotatsu.core.prefs.AppWidgetConfig
abstract class BaseAppWidgetProvider : AppWidgetProvider() {
@CallSuper
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
appWidgetIds.forEach { id ->
val config = AppWidgetConfig(context, javaClass, id)
val views = onUpdateWidget(context, config)
appWidgetManager.updateAppWidget(id, views)
}
}
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
for (id in appWidgetIds) {
AppWidgetConfig(context, javaClass, id).clear()
}
}
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
super.onRestored(context, oldWidgetIds, newWidgetIds)
if (oldWidgetIds.size != newWidgetIds.size) {
return
}
for (i in oldWidgetIds.indices) {
val oldId = oldWidgetIds[i]
val newId = newWidgetIds[i]
val oldConfig = AppWidgetConfig(context, javaClass, oldId)
val newConfig = AppWidgetConfig(context, javaClass, newId)
newConfig.copyFrom(oldConfig)
oldConfig.clear()
}
}
protected abstract fun onUpdateWidget(
context: Context,
config: AppWidgetConfig,
): RemoteViews
}

View File

@@ -5,20 +5,19 @@ import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import androidx.core.content.ContextCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.util.SystemUiController
abstract class BaseFullscreenActivity<B : ViewBinding> :
BaseActivity<B>() {
private lateinit var insetsControllerCompat: WindowInsetsControllerCompat
protected lateinit var systemUiController: SystemUiController
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
with(window) {
insetsControllerCompat = WindowInsetsControllerCompat(this, decorView)
systemUiController = SystemUiController(this)
statusBarColor = Color.TRANSPARENT
navigationBarColor = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
ContextCompat.getColor(this@BaseFullscreenActivity, R.color.dim)
@@ -30,15 +29,7 @@ abstract class BaseFullscreenActivity<B : ViewBinding> :
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
insetsControllerCompat.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
showSystemUI()
}
protected fun hideSystemUI() {
insetsControllerCompat.hide(WindowInsetsCompat.Type.systemBars())
}
protected fun showSystemUI() {
insetsControllerCompat.show(WindowInsetsCompat.Type.systemBars())
// insetsControllerCompat.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
systemUiController.setSystemUiVisible(true)
}
}

View File

@@ -106,12 +106,7 @@ class TrimTransformation(
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as TrimTransformation
return tolerance == other.tolerance
return this === other || (other is TrimTransformation && other.tolerance == tolerance)
}
override fun hashCode(): Int {

View File

@@ -19,13 +19,13 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes
import androidx.core.view.GravityCompat
import androidx.core.view.ancestors
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isLayoutReversed
import org.koitharu.kotatsu.core.util.ext.parents
import org.koitharu.kotatsu.databinding.FastScrollerBinding
import kotlin.math.roundToInt
import com.google.android.material.R as materialR
@@ -522,7 +522,7 @@ class FastScroller @JvmOverloads constructor(
return BubbleSize.entries.getOrNull(ordinal) ?: defaultValue
}
private fun findValidParent(view: View): ViewGroup? = view.parents.firstNotNullOfOrNull { p ->
private fun findValidParent(view: View): ViewGroup? = view.ancestors.firstNotNullOfOrNull { p ->
if (p is FrameLayout || p is ConstraintLayout || p is CoordinatorLayout || p is RelativeLayout) {
p as ViewGroup
} else {

View File

@@ -20,38 +20,19 @@ sealed class DateTimeAgo {
override fun equals(other: Any?): Boolean = other === JustNow
}
class MinutesAgo(val minutes: Int) : DateTimeAgo() {
data class MinutesAgo(val minutes: Int) : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getQuantityString(R.plurals.minutes_ago, minutes, minutes)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MinutesAgo
return minutes == other.minutes
}
override fun hashCode(): Int = minutes
override fun toString() = "minutes_ago_$minutes"
}
class HoursAgo(val hours: Int) : DateTimeAgo() {
data class HoursAgo(val hours: Int) : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getQuantityString(R.plurals.hours_ago, hours, hours)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as HoursAgo
return hours == other.hours
}
override fun hashCode(): Int = hours
override fun toString() = "hours_ago_$hours"
}
@@ -75,26 +56,15 @@ sealed class DateTimeAgo {
override fun equals(other: Any?): Boolean = other === Yesterday
}
class DaysAgo(val days: Int) : DateTimeAgo() {
data class DaysAgo(val days: Int) : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getQuantityString(R.plurals.days_ago, days, days)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DaysAgo
return days == other.days
}
override fun hashCode(): Int = days
override fun toString() = "days_ago_$days"
}
class MonthsAgo(val months: Int) : DateTimeAgo() {
data class MonthsAgo(val months: Int) : DateTimeAgo() {
override fun format(resources: Resources): String {
return if (months == 0) {
resources.getString(R.string.this_month)
@@ -102,19 +72,6 @@ sealed class DateTimeAgo {
resources.getQuantityString(R.plurals.months_ago, months, months)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MonthsAgo
return months == other.months
}
override fun hashCode(): Int {
return months
}
}
class Absolute(private val date: Date) : DateTimeAgo() {

View File

@@ -104,6 +104,7 @@ sealed class AdaptiveSheetBehavior {
companion object {
const val STATE_EXPANDED = SideSheetBehavior.STATE_EXPANDED
const val STATE_COLLAPSED = BottomSheetBehavior.STATE_COLLAPSED
const val STATE_SETTLING = SideSheetBehavior.STATE_SETTLING
const val STATE_DRAGGING = SideSheetBehavior.STATE_DRAGGING
const val STATE_HIDDEN = SideSheetBehavior.STATE_HIDDEN
@@ -114,10 +115,11 @@ sealed class AdaptiveSheetBehavior {
else -> null
}
fun from(lp: CoordinatorLayout.LayoutParams): AdaptiveSheetBehavior? = when (val behavior = lp.behavior) {
is BottomSheetBehavior<*> -> Bottom(behavior)
is SideSheetBehavior<*> -> Side(behavior)
else -> null
}
fun from(lp: CoordinatorLayout.LayoutParams): AdaptiveSheetBehavior? =
when (val behavior = lp.behavior) {
is BottomSheetBehavior<*> -> Bottom(behavior)
is SideSheetBehavior<*> -> Side(behavior)
else -> null
}
}
}

View File

@@ -2,17 +2,19 @@ package org.koitharu.kotatsu.core.ui.sheet
import android.content.Context
import android.util.AttributeSet
import android.view.InputDevice
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.widget.LinearLayout
import androidx.annotation.AttrRes
import androidx.annotation.StringRes
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.withStyledAttributes
import androidx.core.view.ancestors
import androidx.core.view.isGone
import androidx.core.view.isVisible
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.parents
import org.koitharu.kotatsu.databinding.LayoutSheetHeaderAdaptiveBinding
class AdaptiveSheetHeaderBar @JvmOverloads constructor(
@@ -21,7 +23,8 @@ class AdaptiveSheetHeaderBar @JvmOverloads constructor(
@AttrRes defStyleAttr: Int = 0,
) : LinearLayout(context, attrs, defStyleAttr), AdaptiveSheetCallback {
private val binding = LayoutSheetHeaderAdaptiveBinding.inflate(LayoutInflater.from(context), this)
private val binding =
LayoutSheetHeaderAdaptiveBinding.inflate(LayoutInflater.from(context), this)
private var sheetBehavior: AdaptiveSheetBehavior? = null
var title: CharSequence?
@@ -60,6 +63,28 @@ class AdaptiveSheetHeaderBar @JvmOverloads constructor(
super.onDetachedFromWindow()
}
override fun onGenericMotionEvent(event: MotionEvent): Boolean {
val behavior = sheetBehavior ?: return super.onGenericMotionEvent(event)
if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
if (event.actionMasked == MotionEvent.ACTION_SCROLL) {
if (event.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0f) {
behavior.state = if (
behavior is AdaptiveSheetBehavior.Bottom
&& behavior.state == AdaptiveSheetBehavior.STATE_EXPANDED
) {
AdaptiveSheetBehavior.STATE_COLLAPSED
} else {
AdaptiveSheetBehavior.STATE_HIDDEN
}
} else {
behavior.state = AdaptiveSheetBehavior.STATE_EXPANDED
}
return true
}
}
return super.onGenericMotionEvent(event)
}
override fun onStateChanged(sheet: View, newState: Int) {
}
@@ -81,14 +106,9 @@ class AdaptiveSheetHeaderBar @JvmOverloads constructor(
}
private fun findParentSheetBehavior(): AdaptiveSheetBehavior? {
for (p in parents) {
val layoutParams = (p as? View)?.layoutParams
if (layoutParams is CoordinatorLayout.LayoutParams) {
AdaptiveSheetBehavior.from(layoutParams)?.let {
return it
}
}
return ancestors.firstNotNullOfOrNull {
((it as? View)?.layoutParams as? CoordinatorLayout.LayoutParams)
?.let { params -> AdaptiveSheetBehavior.from(params) }
}
return null
}
}

View File

@@ -0,0 +1,60 @@
package org.koitharu.kotatsu.core.ui.util
import android.os.Build
import android.view.View
import android.view.Window
import android.view.WindowInsets
import android.view.WindowInsetsController
import androidx.annotation.RequiresApi
sealed class SystemUiController(
protected val window: Window,
) {
abstract fun setSystemUiVisible(value: Boolean)
@RequiresApi(Build.VERSION_CODES.S)
private class Api30Impl(window: Window) : SystemUiController(window) {
private val insetsController = checkNotNull(window.decorView.windowInsetsController)
override fun setSystemUiVisible(value: Boolean) {
if (value) {
insetsController.show(WindowInsets.Type.systemBars())
insetsController.systemBarsBehavior = WindowInsetsController.BEHAVIOR_DEFAULT
} else {
insetsController.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
insetsController.hide(WindowInsets.Type.systemBars())
}
}
}
@Suppress("DEPRECATION")
private class LegacyImpl(window: Window) : SystemUiController(window) {
override fun setSystemUiVisible(value: Boolean) {
window.decorView.systemUiVisibility = if (value) {
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
} else {
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
}
}
companion object {
operator fun invoke(window: Window): SystemUiController =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
Api30Impl(window)
} else {
LegacyImpl(window)
}
}
}

View File

@@ -139,39 +139,14 @@ class ChipsView @JvmOverloads constructor(
}
}
class ChipModel(
data class ChipModel(
@ColorRes val tint: Int,
val title: CharSequence,
@DrawableRes val icon: Int,
val isCheckable: Boolean,
val isChecked: Boolean,
val data: Any? = null,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ChipModel
if (tint != other.tint) return false
if (title != other.title) return false
if (icon != other.icon) return false
if (isCheckable != other.isCheckable) return false
if (isChecked != other.isChecked) return false
return data == other.data
}
override fun hashCode(): Int {
var result = tint.hashCode()
result = 31 * result + title.hashCode()
result = 31 * result + icon.hashCode()
result = 31 * result + isCheckable.hashCode()
result = 31 * result + isChecked.hashCode()
result = 31 * result + (data?.hashCode() ?: 0)
return result
}
}
)
fun interface OnChipClickListener {

View File

@@ -5,6 +5,7 @@ import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.viewpager.widget.ViewPager
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
@SuppressLint("ClickableViewAccessibility")
class EnhancedViewPager @JvmOverloads constructor(
@@ -25,6 +26,11 @@ class EnhancedViewPager @JvmOverloads constructor(
}
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
return isUserInputEnabled && super.onInterceptTouchEvent(event)
return try {
isUserInputEnabled && super.onInterceptTouchEvent(event)
} catch (e: IllegalArgumentException) {
e.printStackTraceDebug()
false
}
}
}

View File

@@ -118,27 +118,10 @@ class SegmentedBarView @JvmOverloads constructor(
segmentsSizes.add(w)
}
class Segment(
data class Segment(
@FloatRange(from = 0.0, to = 1.0) val percent: Float,
@ColorInt val color: Int,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Segment
if (percent != other.percent) return false
return color == other.color
}
override fun hashCode(): Int {
var result = percent.hashCode()
result = 31 * result + color
return result
}
}
)
private class OutlineProvider : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {

View File

@@ -65,6 +65,9 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
}
fun show() {
if (currentState == STATE_UP) {
return
}
currentAnimator?.cancel()
clearAnimation()
@@ -77,6 +80,9 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
}
fun hide() {
if (currentState == STATE_DOWN) {
return
}
currentAnimator?.cancel()
clearAnimation()
@@ -117,6 +123,7 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
}
internal class SavedState : AbsSavedState {
var currentState = STATE_UP
var translationY = 0F

View File

@@ -0,0 +1,38 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ViewZoomBinding
class ZoomControl @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : LinearLayout(context, attrs), View.OnClickListener {
private val binding = ViewZoomBinding.inflate(LayoutInflater.from(context), this)
var listener: ZoomControlListener? = null
init {
binding.buttonZoomIn.setOnClickListener(this)
binding.buttonZoomOut.setOnClickListener(this)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_zoom_in -> listener?.onZoomIn()
R.id.button_zoom_out -> listener?.onZoomOut()
}
}
interface ZoomControlListener {
fun onZoomIn()
fun onZoomOut()
}
}

View File

@@ -8,6 +8,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.coroutineContext
@Deprecated("", replaceWith = ReplaceWith("CompositeMutex2"))
class CompositeMutex<T : Any> : Set<T> {
private val state = ArrayMap<T, MutableStateFlow<Boolean>>()

View File

@@ -0,0 +1,43 @@
package org.koitharu.kotatsu.core.util
import androidx.collection.ArrayMap
import kotlinx.coroutines.sync.Mutex
class CompositeMutex2<T : Any> : Set<T> {
private val delegates = ArrayMap<T, Mutex>()
override val size: Int
get() = delegates.size
override fun contains(element: T): Boolean {
return delegates.containsKey(element)
}
override fun containsAll(elements: Collection<T>): Boolean {
return elements.all { x -> delegates.containsKey(x) }
}
override fun isEmpty(): Boolean {
return delegates.isEmpty
}
override fun iterator(): Iterator<T> {
return delegates.keys.iterator()
}
suspend fun lock(element: T) {
val mutex = synchronized(delegates) {
delegates.getOrPut(element) {
Mutex()
}
}
mutex.lock()
}
fun unlock(element: T) {
synchronized(delegates) {
delegates.remove(element)?.unlock()
}
}
}

View File

@@ -15,6 +15,7 @@ class ViewBadge(
) : View.OnLayoutChangeListener, DefaultLifecycleObserver {
private var badgeDrawable: BadgeDrawable? = null
private var maxCharacterCount: Int = -1
var counter: Int
get() = badgeDrawable?.number ?: 0
@@ -48,8 +49,16 @@ class ViewBadge(
clearBadge()
}
fun setMaxCharacterCount(value: Int) {
maxCharacterCount = value
badgeDrawable?.maxCharacterCount = value
}
private fun initBadge(): BadgeDrawable {
val badge = BadgeDrawable.create(anchor.context)
if (maxCharacterCount > 0) {
badge.maxCharacterCount = maxCharacterCount
}
anchor.addOnLayoutChangeListener(this)
BadgeUtils.attachBadgeDrawable(badge, anchor)
badgeDrawable = badge

View File

@@ -12,7 +12,6 @@ import coil.request.SuccessResult
import coil.util.CoilUtils
import com.google.android.material.progressindicator.BaseProgressIndicator
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
import org.koitharu.kotatsu.core.ui.image.RegionBitmapDecoder
import org.koitharu.kotatsu.core.util.progress.ImageRequestIndicatorListener
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -29,7 +28,6 @@ fun ImageView.newImageRequest(lifecycleOwner: LifecycleOwner, data: Any?): Image
.data(data)
.lifecycle(lifecycleOwner)
.crossfade(context)
.addListener(CaptchaNotifier(context.applicationContext))
.target(this)
}

View File

@@ -55,3 +55,5 @@ inline fun <reified E : Enum<E>> Collection<E>.toEnumSet(): EnumSet<E> = if (isE
} else {
EnumSet.copyOf(this)
}
fun <E : Enum<E>> Collection<E>.sortedByOrdinal() = sortedBy { it.ordinal }

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -79,3 +80,11 @@ fun <T> Deferred<T>.getCompletionResultOrNull(): Result<T>? = if (isCompleted) {
} else {
null
}
fun <T> Deferred<T>.peek(): T? = if (isCompleted) {
runCatchingCancellable {
getCompleted()
}.getOrNull()
} else {
null
}

View File

@@ -16,8 +16,10 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.fs.FileSequence
import java.io.File
import java.io.FileFilter
import java.nio.file.attribute.BasicFileAttributes
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import kotlin.io.path.readAttributes
fun File.subdir(name: String) = File(this, name).also {
if (!it.exists()) it.mkdirs()
@@ -99,3 +101,10 @@ private suspend fun SequenceScope<File>.listFilesRecursiveImpl(root: File, filte
fun File.children() = FileSequence(this)
fun Sequence<File>.filterWith(filter: FileFilter): Sequence<File> = filter { f -> filter.accept(f) }
val File.creationTime
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
toPath().readAttributes<BasicFileAttributes>().creationTime().toMillis()
} else {
lastModified()
}

View File

@@ -5,6 +5,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
@@ -24,6 +26,17 @@ fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
}
}
fun <T> Flow<T>.onEachWhile(action: suspend (T) -> Boolean): Flow<T> {
var isCalled = false
return onEach {
if (!isCalled) {
isCalled = action(it)
}
}.onCompletion {
isCalled = false
}
}
inline fun <T, R> Flow<List<T>>.mapItems(crossinline transform: (T) -> R): Flow<List<R>> {
return map { list -> list.map(transform) }
}
@@ -72,7 +85,7 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
transform: suspend (T1, T2, T3, T4, T5, T6) -> R
transform: suspend (T1, T2, T3, T4, T5, T6) -> R,
): Flow<R> = combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> ->
transform(
args[0] as T1,
@@ -83,3 +96,7 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
args[5] as T6,
)
}
suspend fun <T : Any> Flow<T?>.firstNotNull(): T = checkNotNull(first { x -> x != null })
suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x != null }

View File

@@ -27,6 +27,7 @@ import java.net.SocketTimeoutException
import java.net.UnknownHostException
private const val MSG_NO_SPACE_LEFT = "No space left on device"
private const val IMAGE_FORMAT_NO_SUPPORTED = "Image format not supported"
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is AuthRequiredException -> resources.getString(R.string.auth_required)
@@ -81,6 +82,7 @@ private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String
private fun getDisplayMessage(msg: String?, resources: Resources): String? = when {
msg.isNullOrEmpty() -> null
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
msg.contains(IMAGE_FORMAT_NO_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
else -> null
}

View File

@@ -2,16 +2,18 @@ package org.koitharu.kotatsu.core.util.ext
import android.app.Activity
import android.graphics.Rect
import android.os.Build
import android.view.View
import android.view.View.MeasureSpec
import android.view.ViewGroup
import android.view.ViewParent
import android.view.inputmethod.InputMethodManager
import android.widget.Checkable
import android.widget.CompoundButton
import androidx.core.view.children
import androidx.core.view.descendants
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.progressindicator.BaseProgressIndicator
import com.google.android.material.slider.Slider
import com.google.android.material.tabs.TabLayout
import kotlin.math.roundToInt
@@ -88,23 +90,8 @@ fun Slider.setValueRounded(newValue: Float) {
value = roundedValue.coerceIn(valueFrom, valueTo)
}
fun <T : View> ViewGroup.findViewsByType(clazz: Class<T>): Sequence<T> {
if (childCount == 0) {
return emptySequence()
}
return sequence {
for (view in children) {
if (clazz.isInstance(view)) {
yield(clazz.cast(view)!!)
} else if (view is ViewGroup && view.childCount != 0) {
yieldAll(view.findViewsByType(clazz))
}
}
}
}
fun RecyclerView.invalidateNestedItemDecorations() {
findViewsByType(RecyclerView::class.java).forEach {
descendants.filterIsInstance<RecyclerView>().forEach {
it.invalidateItemDecorations()
}
}
@@ -112,15 +99,6 @@ fun RecyclerView.invalidateNestedItemDecorations() {
val View.parentView: ViewGroup?
get() = parent as? ViewGroup
val View.parents: Sequence<ViewParent>
get() = sequence {
var p: ViewParent? = parent
while (p != null) {
yield(p)
p = p.parent
}
}
fun View.measureDimension(desiredSize: Int, measureSpec: Int): Int {
var result: Int
val specMode = MeasureSpec.getMode(measureSpec)
@@ -155,3 +133,17 @@ fun TabLayout.setTabsEnabled(enabled: Boolean) {
getTabAt(i)?.view?.isEnabled = enabled
}
}
fun BaseProgressIndicator<*>.showOrHide(value: Boolean) {
if (value) {
if (!isVisible) show()
} else {
if (isVisible) hide()
}
}
fun View.setOnContextClickListenerCompat(listener: View.OnLongClickListener) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setOnContextClickListener(listener::onLongClick)
}
}

View File

@@ -0,0 +1,43 @@
package org.koitharu.kotatsu.details.data
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.reader.data.filterChapters
data class MangaDetails(
private val manga: Manga,
private val localManga: LocalManga?,
val description: CharSequence?,
val isLoaded: Boolean,
) {
val id: Long
get() = manga.id
val chapters: Map<String?, List<MangaChapter>> = manga.chapters?.groupBy { it.branch }.orEmpty()
val branches: Set<String?>
get() = chapters.keys
val allChapters: List<MangaChapter>
get() = manga.chapters.orEmpty()
val isLocal
get() = manga.isLocal
val local: LocalManga?
get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null
fun toManga() = manga
fun filterChapters(branch: String?) = MangaDetails(
manga = manga.filterChapters(branch),
localManga = localManga?.run {
copy(manga = manga.filterChapters(branch))
},
description = description,
isLoaded = isLoaded,
)
}

View File

@@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.details.domain.model.DoubleManga
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalMangaRepository
@@ -20,7 +20,7 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import javax.inject.Inject
@Deprecated("")
/* TODO: remove */
class DetailsInteractor @Inject constructor(
private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository,
@@ -66,15 +66,26 @@ class DetailsInteractor @Inject constructor(
}
}
suspend fun updateLocal(subject: DoubleManga?, localManga: LocalManga): DoubleManga? {
return if (subject?.any?.id == localManga.manga.id) {
subject.copy(
localManga = runCatchingCancellable {
localMangaRepository.getDetails(localManga.manga)
},
)
suspend fun updateLocal(subject: MangaDetails?, localManga: LocalManga): MangaDetails? {
subject ?: return null
return if (subject.id == localManga.manga.id) {
if (subject.isLocal) {
subject.copy(
manga = localManga.manga,
)
} else {
subject.copy(
localManga = runCatchingCancellable {
localManga.copy(
manga = localMangaRepository.getDetails(localManga.manga),
)
}.getOrNull() ?: subject.local,
)
}
} else {
subject
}
}
suspend fun findLocal(seed: Manga) = localMangaRepository.getRemoteManga(seed)
}

View File

@@ -0,0 +1,85 @@
package org.koitharu.kotatsu.details.domain
import android.text.Html
import android.text.SpannableString
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import androidx.core.text.getSpans
import androidx.core.text.parseAsHtml
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.peek
import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.explore.domain.RecoverMangaUseCase
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.recoverNotNull
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
class DetailsLoadUseCase @Inject constructor(
private val mangaDataRepository: MangaDataRepository,
private val localMangaRepository: LocalMangaRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val recoverUseCase: RecoverMangaUseCase,
private val imageGetter: Html.ImageGetter,
) {
operator fun invoke(intent: MangaIntent): Flow<MangaDetails> = channelFlow {
val manga = requireNotNull(mangaDataRepository.resolveIntent(intent)) {
"Cannot resolve intent $intent"
}
val local = if (!manga.isLocal) {
async {
localMangaRepository.findSavedManga(manga)
}
} else {
null
}
send(MangaDetails(manga, null, null, false))
val details = getDetails(manga)
send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false), false))
send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true), true))
}
private suspend fun getDetails(seed: Manga) = runCatchingCancellable {
val repository = mangaRepositoryFactory.create(seed.source)
repository.getDetails(seed)
}.recoverNotNull { e ->
if (e is NotFoundException) {
recoverUseCase(seed)
} else {
null
}
}.getOrThrow()
private suspend fun String.parseAsHtml(withImages: Boolean): CharSequence? {
return if (withImages) {
runInterruptible(Dispatchers.IO) {
parseAsHtml(imageGetter = imageGetter)
}.filterSpans()
} else {
runInterruptible(Dispatchers.Default) {
parseAsHtml()
}.filterSpans().sanitize()
}.takeUnless { it.isBlank() }
}
private fun Spanned.filterSpans(): Spanned {
val spannable = SpannableString.valueOf(this)
val spans = spannable.getSpans<ForegroundColorSpan>()
for (span in spans) {
spannable.removeSpan(span)
}
return spannable
}
}

View File

@@ -1,74 +0,0 @@
package org.koitharu.kotatsu.details.domain
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.details.domain.model.DoubleManga
import org.koitharu.kotatsu.explore.domain.RecoverMangaUseCase
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.recoverNotNull
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
class DoubleMangaLoadUseCase @Inject constructor(
private val mangaDataRepository: MangaDataRepository,
private val localMangaRepository: LocalMangaRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val recoverUseCase: RecoverMangaUseCase,
) {
suspend operator fun invoke(manga: Manga): DoubleManga = coroutineScope {
val remoteDeferred = async(Dispatchers.Default) { loadRemote(manga) }
val localDeferred = async(Dispatchers.Default) { loadLocal(manga) }
DoubleManga(
remoteManga = remoteDeferred.await(),
localManga = localDeferred.await(),
)
}
suspend operator fun invoke(mangaId: Long): DoubleManga {
val manga = mangaDataRepository.findMangaById(mangaId) ?: throwNFE()
return invoke(manga)
}
suspend operator fun invoke(intent: MangaIntent): DoubleManga {
val manga = mangaDataRepository.resolveIntent(intent) ?: throwNFE()
return invoke(manga)
}
private suspend fun loadLocal(manga: Manga): Result<Manga>? {
return runCatchingCancellable {
if (manga.isLocal) {
localMangaRepository.getDetails(manga)
} else {
localMangaRepository.findSavedManga(manga)?.manga
} ?: return null
}
}
private suspend fun loadRemote(manga: Manga): Result<Manga>? {
return runCatchingCancellable {
val seed = if (manga.isLocal) {
localMangaRepository.getRemoteManga(manga)
} else {
manga
} ?: return null
val repository = mangaRepositoryFactory.create(seed.source)
repository.getDetails(seed)
}.recoverNotNull { e ->
if (e is NotFoundException) {
recoverUseCase(manga)
} else {
null
}
}
}
private fun throwNFE(): Nothing = throw NotFoundException("Cannot find manga", "")
}

View File

@@ -0,0 +1,55 @@
package org.koitharu.kotatsu.details.domain
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.model.findChapter
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject
class ProgressUpdateUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val database: MangaDatabase,
private val localMangaRepository: LocalMangaRepository,
) {
suspend operator fun invoke(manga: Manga): Float {
val history = database.historyDao.find(manga.id) ?: return PROGRESS_NONE
val seed = if (manga.isLocal) {
localMangaRepository.getRemoteManga(manga) ?: manga
} else {
manga
}
val repo = mangaRepositoryFactory.create(seed.source)
val details = if (manga.source != seed.source || seed.chapters.isNullOrEmpty()) {
repo.getDetails(seed)
} else {
seed
}
val chapter = details.findChapter(history.chapterId) ?: return PROGRESS_NONE
val chapters = details.getChapters(chapter.branch) ?: return PROGRESS_NONE
val chaptersCount = chapters.size
if (chaptersCount == 0) {
return PROGRESS_NONE
}
val chapterIndex = chapters.indexOfFirst { x -> x.id == history.chapterId }
val pagesCount = repo.getPages(chapter).size
if (pagesCount == 0) {
return PROGRESS_NONE
}
val pagePercent = (history.page + 1) / pagesCount.toFloat()
val ppc = 1f / chaptersCount
val result = ppc * chapterIndex + ppc * pagePercent
if (result != history.percent) {
database.historyDao.update(
history.copy(
chapterId = chapter.id,
percent = result,
),
)
}
return result
}
}

View File

@@ -1,76 +0,0 @@
package org.koitharu.kotatsu.details.domain.model
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.data.filterChapters
data class DoubleManga(
private val remoteManga: Result<Manga>?,
private val localManga: Result<Manga>?,
) {
constructor(manga: Manga) : this(
remoteManga = if (manga.source != MangaSource.LOCAL) Result.success(manga) else null,
localManga = if (manga.source == MangaSource.LOCAL) Result.success(manga) else null,
)
val remote: Manga?
get() = remoteManga?.getOrNull()
val local: Manga?
get() = localManga?.getOrNull()
val any: Manga?
get() = remote ?: local
val hasRemote: Boolean
get() = remoteManga?.isSuccess == true
val hasLocal: Boolean
get() = localManga?.isSuccess == true
val chapters: List<MangaChapter>? by lazy(LazyThreadSafetyMode.PUBLICATION) {
mergeChapters()
}
fun requireAny(): Manga {
val result = remoteManga?.getOrNull() ?: localManga?.getOrNull()
if (result != null) {
return result
}
throw (
remoteManga?.exceptionOrNull()
?: localManga?.exceptionOrNull()
?: IllegalStateException("No online either local manga available")
)
}
fun filterChapters(branch: String?) = DoubleManga(
remoteManga?.map { it.filterChapters(branch) },
localManga?.map { it.filterChapters(branch) },
)
private fun mergeChapters(): List<MangaChapter>? {
val remoteChapters = remote?.chapters
val localChapters = local?.chapters
if (localChapters == null && remoteChapters == null) {
return null
}
val localMap = if (!localChapters.isNullOrEmpty()) {
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
} else {
null
}
val result = ArrayList<MangaChapter>(maxOf(remoteChapters?.size ?: 0, localChapters?.size ?: 0))
remoteChapters?.forEach { r ->
localMap?.remove(r.id)?.let { l ->
result.add(l)
} ?: result.add(r)
}
localMap?.values?.let {
result.addAll(it)
}
return result
}
}

View File

@@ -5,8 +5,9 @@ import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
@@ -34,12 +35,13 @@ class MangaPrefetchService : CoroutineIntentService() {
override suspend fun processIntent(startId: Int, intent: Intent) {
when (intent.action) {
ACTION_PREFETCH_DETAILS -> prefetchDetails(
manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga ?: return,
manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga
?: return,
)
ACTION_PREFETCH_PAGES -> prefetchPages(
chapter = intent.getParcelableExtraCompat<ParcelableMangaChapters>(EXTRA_CHAPTER)
?.chapters?.singleOrNull() ?: return,
chapter = intent.getParcelableExtraCompat<ParcelableChapter>(EXTRA_CHAPTER)?.chapter
?: return,
)
ACTION_PREFETCH_LAST -> prefetchLast()
@@ -71,7 +73,7 @@ class MangaPrefetchService : CoroutineIntentService() {
val chapter = if (history == null) {
chapters.firstOrNull()
} else {
chapters.find { x -> x.id == history.chapterId } ?: chapters.firstOrNull()
chapters.findById(history.chapterId) ?: chapters.firstOrNull()
} ?: return
runCatchingCancellable { repo.getPages(chapter) }
}
@@ -88,7 +90,7 @@ class MangaPrefetchService : CoroutineIntentService() {
if (!isPrefetchAvailable(context, manga.source)) return
val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_DETAILS
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga))
context.startService(intent)
}
@@ -96,7 +98,7 @@ class MangaPrefetchService : CoroutineIntentService() {
if (!isPrefetchAvailable(context, chapter.source)) return
val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_PAGES
intent.putExtra(EXTRA_CHAPTER, ParcelableMangaChapters(listOf(chapter)))
intent.putExtra(EXTRA_CHAPTER, ParcelableChapter(chapter))
try {
context.startService(intent)
} catch (e: IllegalStateException) {
@@ -119,7 +121,10 @@ class MangaPrefetchService : CoroutineIntentService() {
if (context.isPowerSaveMode()) {
return false
}
val entryPoint = EntryPointAccessors.fromApplication(context, PrefetchCompanionEntryPoint::class.java)
val entryPoint = EntryPointAccessors.fromApplication(
context,
PrefetchCompanionEntryPoint::class.java,
)
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled
}
}

View File

@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.details.ui
import android.view.InputDevice
import android.view.MotionEvent
import android.view.View
import android.view.View.OnLayoutChangeListener
import androidx.activity.OnBackPressedCallback
@@ -12,7 +14,7 @@ class ChaptersBottomSheetMediator(
private val behavior: BottomSheetBehavior<*>,
) : OnBackPressedCallback(false),
ActionModeListener,
OnLayoutChangeListener {
OnLayoutChangeListener, View.OnGenericMotionListener {
private var lockCounter = 0
@@ -55,6 +57,20 @@ class ChaptersBottomSheetMediator(
}
}
override fun onGenericMotion(v: View?, event: MotionEvent): Boolean {
if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
if (event.actionMasked == MotionEvent.ACTION_SCROLL) {
if (event.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0f) {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
} else {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
return true
}
}
return false
}
fun lock() {
lockCounter++
behavior.isDraggable = lockCounter <= 0

View File

@@ -2,33 +2,30 @@ package org.koitharu.kotatsu.details.ui
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet
fun mapChapters(
remoteManga: Manga?,
localManga: Manga?,
fun MangaDetails.mapChapters(
history: MangaHistory?,
newCount: Int,
branch: String?,
bookmarks: List<Bookmark>,
): List<ChapterListItem> {
val remoteChapters = remoteManga?.getChapters(branch).orEmpty()
val localChapters = localManga?.getChapters(branch).orEmpty()
val remoteChapters = chapters[branch].orEmpty()
val localChapters = local?.manga?.getChapters(branch).orEmpty()
if (remoteChapters.isEmpty() && localChapters.isEmpty()) {
return emptyList()
}
val bookmarked = bookmarks.mapToSet { it.chapterId }
val currentId = history?.chapterId ?: 0L
val newFrom = if (newCount == 0 || remoteChapters.isEmpty()) Int.MAX_VALUE else remoteChapters.size - newCount
val chaptersSize = maxOf(remoteChapters.size, localChapters.size)
val ids = buildSet(chaptersSize) {
val ids = buildSet(maxOf(remoteChapters.size, localChapters.size)) {
remoteChapters.mapTo(this) { it.id }
localChapters.mapTo(this) { it.id }
}
val result = ArrayList<ChapterListItem>(chaptersSize)
val result = ArrayList<ChapterListItem>(ids.size)
val localMap = if (localChapters.isNotEmpty()) {
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
} else {
@@ -40,7 +37,7 @@ fun mapChapters(
if (chapter.id == currentId) {
isUnread = true
}
result += chapter.toListItem(
result += (local ?: chapter).toListItem(
isCurrent = chapter.id == currentId,
isUnread = isUnread,
isNew = isUnread && result.size >= newFrom,
@@ -57,7 +54,7 @@ fun mapChapters(
isCurrent = chapter.id == currentId,
isUnread = isUnread,
isNew = false,
isDownloaded = remoteManga != null,
isDownloaded = !isLocal,
isBookmarked = chapter.id in bookmarked,
)
}

View File

@@ -39,7 +39,6 @@ import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.util.ViewBadge
import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.getThemeColor
@@ -49,6 +48,7 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.core.util.ext.setNavigationIconSafe
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService
@@ -74,7 +74,6 @@ class DetailsActivity :
@Inject
lateinit var appShortcutManager: AppShortcutManager
private lateinit var viewBadge: ViewBadge
private var buttonTip: WeakReference<ButtonTip>? = null
private val viewModel: DetailsViewModel by viewModels()
@@ -89,8 +88,8 @@ class DetailsActivity :
}
viewBinding.buttonRead.setOnClickListener(this)
viewBinding.buttonRead.setOnLongClickListener(this)
viewBinding.buttonRead.setOnContextClickListenerCompat(this)
viewBinding.buttonDropdown.setOnClickListener(this)
viewBadge = ViewBadge(viewBinding.buttonRead, this)
if (viewBinding.layoutBottom != null) {
val behavior = BottomSheetBehavior.from(checkNotNull(viewBinding.layoutBottom))
@@ -103,6 +102,7 @@ class DetailsActivity :
viewBinding.toolbarChapters?.setNavigationOnClickListener {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
viewBinding.toolbarChapters?.setOnGenericMotionListener(bsMediator)
} else {
chaptersMenuProvider = ChaptersMenuProvider(viewModel, null)
addMenuProvider(chaptersMenuProvider)
@@ -110,7 +110,6 @@ class DetailsActivity :
onBackPressedDispatcher.addCallback(chaptersMenuProvider)
viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated)
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
viewModel.onError.observeEvent(
this,
@@ -134,13 +133,21 @@ class DetailsActivity :
viewBinding.toolbarChapters?.subtitle = it
viewBinding.textViewSubtitle?.textAndVisible = it
}
viewModel.isChaptersReversed.observe(this, MenuInvalidator(viewBinding.toolbarChapters ?: this))
viewModel.favouriteCategories.observe(this, MenuInvalidator(this))
viewModel.isChaptersReversed.observe(
this,
MenuInvalidator(viewBinding.toolbarChapters ?: this),
)
val menuInvalidator = MenuInvalidator(this)
viewModel.favouriteCategories.observe(this, menuInvalidator)
viewModel.remoteManga.observe(this, menuInvalidator)
viewModel.branches.observe(this) {
viewBinding.buttonDropdown.isVisible = it.size > 1
}
viewModel.chapters.observe(this, PrefetchObserver(this))
viewModel.onDownloadStarted.observeEvent(this, DownloadStartedObserver(viewBinding.containerDetails))
viewModel.onDownloadStarted.observeEvent(
this,
DownloadStartedObserver(viewBinding.containerDetails),
)
addMenuProvider(
DetailsMenuProvider(
@@ -243,7 +250,11 @@ class DetailsActivity :
right = insets.right,
)
if (insets.bottom > 0) {
window.setNavigationBarTransparentCompat(this, viewBinding.layoutBottom?.elevation ?: 0f, 0.9f)
window.setNavigationBarTransparentCompat(
this,
viewBinding.layoutBottom?.elevation ?: 0f,
0.9f,
)
}
viewBinding.cardChapters?.updateLayoutParams<MarginLayoutParams> {
bottomMargin = insets.bottom + marginEnd
@@ -265,18 +276,23 @@ class DetailsActivity :
}
val text = when {
!info.isValid -> getString(R.string.loading_)
info.currentChapter >= 0 -> getString(R.string.chapter_d_of_d, info.currentChapter + 1, info.totalChapters)
info.currentChapter >= 0 -> getString(
R.string.chapter_d_of_d,
info.currentChapter + 1,
info.totalChapters,
)
info.totalChapters == 0 -> getString(R.string.no_chapters)
else -> resources.getQuantityString(R.plurals.chapters, info.totalChapters, info.totalChapters)
else -> resources.getQuantityString(
R.plurals.chapters,
info.totalChapters,
info.totalChapters,
)
}
viewBinding.toolbarChapters?.title = text
viewBinding.textViewTitle?.text = text
}
private fun onNewChaptersChanged(newChapters: Int) {
viewBadge.counter = newChapters
}
private fun showBranchPopupMenu(v: View) {
val menu = PopupMenu(v.context, v)
val branches = viewModel.branches.value
@@ -286,7 +302,12 @@ class DetailsActivity :
append(' ')
append(' ')
inSpans(
ForegroundColorSpan(v.context.getThemeColor(android.R.attr.textColorSecondary, Color.LTGRAY)),
ForegroundColorSpan(
v.context.getThemeColor(
android.R.attr.textColorSecondary,
Color.LTGRAY,
),
),
RelativeSizeSpan(0.74f),
) {
append(branch.count.toString())
@@ -305,7 +326,8 @@ class DetailsActivity :
val manga = viewModel.manga.value ?: return
val chapterId = viewModel.historyInfo.value.history?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
val snackbar = makeSnackbar(getString(R.string.chapter_is_missing), Snackbar.LENGTH_SHORT)
val snackbar =
makeSnackbar(getString(R.string.chapter_is_missing), Snackbar.LENGTH_SHORT)
snackbar.show()
} else {
startActivity(
@@ -331,7 +353,10 @@ class DetailsActivity :
view.isVisible = isVisible
}
private fun makeSnackbar(text: CharSequence, @BaseTransientBottomBar.Duration duration: Int): Snackbar {
private fun makeSnackbar(
text: CharSequence,
@BaseTransientBottomBar.Duration duration: Int,
): Snackbar {
val sb = Snackbar.make(viewBinding.containerDetails, text, duration)
if (viewBinding.layoutBottom?.isVisible == true) {
sb.anchorView = viewBinding.toolbarChapters
@@ -369,7 +394,7 @@ class DetailsActivity :
fun newIntent(context: Context, manga: Manga): Intent {
return Intent(context, DetailsActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
}
fun newIntent(context: Context, mangaId: Long): Intent {

View File

@@ -11,6 +11,8 @@ import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.text.buildSpannedString
import androidx.core.text.color
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
@@ -21,6 +23,7 @@ import coil.request.SuccessResult
import coil.util.CoilUtils
import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
@@ -37,12 +40,14 @@ import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.crossfade
import org.koitharu.kotatsu.core.util.ext.drawableTop
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.isTextTruncated
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
@@ -69,13 +74,14 @@ import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorShee
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint
class DetailsFragment :
BaseFragment<FragmentDetailsBinding>(),
View.OnClickListener,
ChipsView.OnChipClickListener,
OnListItemClickListener<Bookmark>, ViewTreeObserver.OnDrawListener {
OnListItemClickListener<Bookmark>, ViewTreeObserver.OnDrawListener, View.OnLayoutChangeListener {
@Inject
lateinit var coil: ImageLoader
@@ -99,6 +105,7 @@ class DetailsFragment :
binding.buttonScrobblingMore.setOnClickListener(this)
binding.buttonRelatedMore.setOnClickListener(this)
binding.infoLayout.textViewSource.setOnClickListener(this)
binding.textViewDescription.addOnLayoutChangeListener(this)
binding.textViewDescription.viewTreeObserver.addOnDrawListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.chipsTags.onChipClickListener = this
@@ -112,9 +119,9 @@ class DetailsFragment :
viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)
viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged)
viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged)
viewModel.relatedManga.observe(viewLifecycleOwner, ::onRelatedMangaChanged)
combine(viewModel.chapters, viewModel.newChaptersCount, ::Pair).observe(viewLifecycleOwner, ::onChaptersChanged)
}
override fun onItemClick(item: Bookmark, view: View) {
@@ -144,6 +151,22 @@ class DetailsFragment :
}
}
override fun onLayoutChange(
v: View?,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int
) {
with(viewBinding ?: return) {
buttonDescriptionMore.isVisible = textViewDescription.isTextTruncated
}
}
private fun onMangaUpdated(manga: Manga) {
with(requireViewBinding()) {
// Main
@@ -159,21 +182,22 @@ class DetailsFragment :
}
when (manga.state) {
MangaState.FINISHED -> {
infoLayout.textViewState.apply {
textAndVisible = resources.getString(R.string.state_finished)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_finished)
}
MangaState.FINISHED -> infoLayout.textViewState.apply {
textAndVisible = resources.getString(R.string.state_finished)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_finished)
}
MangaState.ONGOING -> {
infoLayout.textViewState.apply {
textAndVisible = resources.getString(R.string.state_ongoing)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_ongoing)
}
MangaState.ONGOING -> infoLayout.textViewState.apply {
textAndVisible = resources.getString(R.string.state_ongoing)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_ongoing)
}
else -> infoLayout.textViewState.isVisible = false
MangaState.ABANDONED -> infoLayout.textViewState.apply {
textAndVisible = resources.getString(R.string.state_abandoned)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_abandoned)
}
null -> infoLayout.textViewState.isVisible = false
}
if (manga.source == MangaSource.LOCAL) {
infoLayout.textViewSource.isVisible = false
@@ -189,14 +213,28 @@ class DetailsFragment :
}
}
private fun onChaptersChanged(chapters: List<ChapterListItem>?) {
private fun onChaptersChanged(data: Pair<List<ChapterListItem>?, Int>) {
val (chapters, newChapters) = data
val infoLayout = requireViewBinding().infoLayout
if (chapters.isNullOrEmpty()) {
infoLayout.textViewChapters.isVisible = false
} else {
val count = chapters.countChaptersByBranch()
infoLayout.textViewChapters.isVisible = true
infoLayout.textViewChapters.text = resources.getQuantityString(R.plurals.chapters, count, count)
val chaptersText = resources.getQuantityString(R.plurals.chapters, count, count)
infoLayout.textViewChapters.text = if (newChapters == 0) {
chaptersText
} else {
buildSpannedString {
append(chaptersText)
append(' ')
color(infoLayout.textViewChapters.context.getThemeColor(materialR.attr.colorError)) {
append("(+")
append(newChapters.toString())
append(')')
}
}
}
}
}
@@ -207,7 +245,6 @@ class DetailsFragment :
} else {
tv.text = description
}
requireViewBinding().buttonDescriptionMore.isVisible = tv.isTextTruncated
}
private fun onLocalSizeChanged(size: Long) {
@@ -247,11 +284,7 @@ class DetailsFragment :
}
private fun onLoadingStateChanged(isLoading: Boolean) {
if (isLoading) {
requireViewBinding().progressBar.show()
} else {
requireViewBinding().progressBar.hide()
}
requireViewBinding().progressBar.showOrHide(isLoading)
}
private fun onBookmarksChanged(bookmarks: List<Bookmark>) {

View File

@@ -42,6 +42,7 @@ class DetailsMenuProvider(
menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null
menu.findItem(R.id.action_favourite).setIcon(
if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline,
)
@@ -88,6 +89,12 @@ class DetailsMenuProvider(
}
}
R.id.action_online -> {
viewModel.remoteManga.value?.let {
activity.startActivity(DetailsActivity.newIntent(activity, it))
}
}
R.id.action_related -> {
viewModel.manga.value?.let {
activity.startActivity(MultiSearchActivity.newIntent(activity, it.title))

View File

@@ -1,12 +1,5 @@
package org.koitharu.kotatsu.details.ui
import android.text.Html
import android.text.SpannableString
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import androidx.core.net.toUri
import androidx.core.text.getSpans
import androidx.core.text.parseAsHtml
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -17,22 +10,21 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
@@ -40,16 +32,15 @@ import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.combine
import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.onEachWhile
import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.domain.BranchComparator
import org.koitharu.kotatsu.details.domain.DetailsInteractor
import org.koitharu.kotatsu.details.domain.DoubleMangaLoadUseCase
import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
import org.koitharu.kotatsu.details.domain.RelatedMangaUseCase
import org.koitharu.kotatsu.details.domain.model.DoubleManga
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.model.MangaBranch
@@ -73,21 +64,19 @@ class DetailsViewModel @Inject constructor(
private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
private val imageGetter: Html.ImageGetter,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
private val downloadScheduler: DownloadWorker.Scheduler,
private val interactor: DetailsInteractor,
savedStateHandle: SavedStateHandle,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
private val doubleMangaLoadUseCase: DoubleMangaLoadUseCase,
private val relatedMangaUseCase: RelatedMangaUseCase,
private val extraProvider: ListExtraProvider,
networkState: NetworkState,
private val detailsLoadUseCase: DetailsLoadUseCase,
private val progressUpdateUseCase: ProgressUpdateUseCase,
) : BaseViewModel() {
private val intent = MangaIntent(savedStateHandle)
private val mangaId = intent.mangaId
private val doubleManga: MutableStateFlow<DoubleManga?> = MutableStateFlow(intent.manga?.let { DoubleManga(it) })
private var loadingJob: Job
val onShowToast = MutableEventFlow<Int>()
@@ -95,8 +84,9 @@ class DetailsViewModel @Inject constructor(
val onSelectChapter = MutableEventFlow<Long>()
val onDownloadStarted = MutableEventFlow<Unit>()
val manga = doubleManga.map { it?.any }
.stateIn(viewModelScope, SharingStarted.Eagerly, doubleManga.value?.any)
val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) })
val manga = details.map { x -> x?.toManga() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val history = historyRepository.observeOne(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
@@ -104,8 +94,15 @@ class DetailsViewModel @Inject constructor(
val favouriteCategories = interactor.observeIsFavourite(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
val newChaptersCount = interactor.observeNewChapters(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
val remoteManga = MutableStateFlow<Manga?>(null)
val newChaptersCount = details.flatMapLatest { d ->
if (d?.isLocal == false) {
interactor.observeNewChapters(mangaId)
} else {
flowOf(0)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
private val chaptersQuery = MutableStateFlow("")
val selectedBranch = MutableStateFlow<String?>(null)
@@ -133,28 +130,17 @@ class DetailsViewModel @Inject constructor(
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
val localSize = doubleManga
.map {
val local = it?.local
if (local != null) {
val file = local.url.toUri().toFileOrNull()
file?.computeSize() ?: 0L
} else {
0L
}
val localSize = details
.map { it?.local }
.distinctUntilChanged()
.map { local ->
local?.file?.computeSize() ?: 0L
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(), 0)
val description = manga
.distinctUntilChangedBy { it?.description.orEmpty() }
.transformLatest {
val description = it?.description
if (description.isNullOrEmpty()) {
emit(null)
} else {
emit(description.parseAsHtml().filterSpans().sanitize())
emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans())
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), null)
@Deprecated("")
val description = details
.map { it?.description }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, null)
val onMangaRemoved = MutableEventFlow<Manga>()
val isScrobblingAvailable: Boolean
@@ -163,9 +149,7 @@ class DetailsViewModel @Inject constructor(
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val relatedManga: StateFlow<List<MangaItemModel>> = doubleManga.map {
it?.remote
}.distinctUntilChangedBy { it?.id }
val relatedManga: StateFlow<List<MangaItemModel>> = manga
.mapLatest {
if (it != null && settings.isRelatedMangaEnabled) {
relatedMangaUseCase.invoke(it)?.toUi(ListMode.GRID, extraProvider).orEmpty()
@@ -176,33 +160,32 @@ class DetailsViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
val branches: StateFlow<List<MangaBranch>> = combine(
doubleManga,
details,
selectedBranch,
) { m, b ->
val chapters = m?.chapters
if (chapters.isNullOrEmpty()) return@combine emptyList()
chapters.groupBy { x -> x.branch }
(m?.chapters ?: return@combine emptyList())
.map { x -> MangaBranch(x.key, x.value.size, x.key == b) }
.sortedWith(BranchComparator())
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val isChaptersEmpty: StateFlow<Boolean> = combine(
doubleManga,
isLoading,
) { manga, loading ->
manga?.any != null && manga.chapters.isNullOrEmpty() && !loading
val isChaptersEmpty: StateFlow<Boolean> = details.map {
it != null && it.isLoaded && it.allChapters.isEmpty()
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
val chapters = combine(
combine(
doubleManga,
details,
history,
selectedBranch,
newChaptersCount,
bookmarks,
networkState,
) { manga, history, branch, news, bookmarks, isOnline ->
mapChapters(manga?.remote?.takeIf { isOnline }, manga?.local, history, news, branch, bookmarks)
) { manga, history, branch, news, bookmarks ->
manga?.mapChapters(
history,
news,
branch,
bookmarks,
).orEmpty()
},
isChaptersReversed,
chaptersQuery,
@@ -225,6 +208,17 @@ class DetailsViewModel @Inject constructor(
onShowTip.call(Unit)
}
}
launchJob(Dispatchers.Default) {
val manga = details.firstOrNull { !it?.chapters.isNullOrEmpty() } ?: return@launchJob
val h = history.firstOrNull()
if (h != null) {
progressUpdateUseCase(manga.toManga())
}
}
launchJob(Dispatchers.Default) {
val manga = details.firstOrNull { it != null && it.isLocal } ?: return@launchJob
remoteManga.value = interactor.findLocal(manga.toManga())
}
}
fun reload() {
@@ -233,7 +227,7 @@ class DetailsViewModel @Inject constructor(
}
fun deleteLocal() {
val m = doubleManga.value?.local
val m = details.value?.local?.manga
if (m == null) {
onShowToast.call(R.string.file_not_found)
return
@@ -286,13 +280,13 @@ class DetailsViewModel @Inject constructor(
fun markChapterAsCurrent(chapterId: Long) {
launchJob(Dispatchers.Default) {
val manga = checkNotNull(doubleManga.value)
val chapters = checkNotNull(manga.filterChapters(selectedBranchValue).chapters)
val manga = checkNotNull(details.value)
val chapters = checkNotNull(manga.chapters[selectedBranchValue])
val chapterIndex = chapters.indexOfFirst { it.id == chapterId }
check(chapterIndex in chapters.indices) { "Chapter not found" }
val percent = chapterIndex / chapters.size.toFloat()
historyRepository.addOrUpdate(
manga = manga.requireAny(),
manga = manga.toManga(),
chapterId = chapterId,
page = 0,
scroll = 0,
@@ -304,7 +298,7 @@ class DetailsViewModel @Inject constructor(
fun download(chaptersIds: Set<Long>?) {
launchJob(Dispatchers.Default) {
downloadScheduler.schedule(
doubleManga.requireValue().requireAny(),
details.requireValue().toManga(),
chaptersIds,
)
onDownloadStarted.call(Unit)
@@ -324,12 +318,19 @@ class DetailsViewModel @Inject constructor(
}
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
val result = doubleMangaLoadUseCase(intent)
val manga = result.requireAny()
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = manga.getPreferredBranch(hist)
doubleManga.value = result
detailsLoadUseCase.invoke(intent)
.onEachWhile {
if (it.allChapters.isEmpty()) {
return@onEachWhile false
}
val manga = it.toManga()
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = manga.getPreferredBranch(hist)
true
}.collect {
details.value = it
}
}
private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> {
@@ -344,21 +345,12 @@ class DetailsViewModel @Inject constructor(
private suspend fun onDownloadComplete(downloadedManga: LocalManga?) {
downloadedManga ?: return
launchJob {
doubleManga.update {
details.update {
interactor.updateLocal(it, downloadedManga)
}
}
}
private fun Spanned.filterSpans(): CharSequence {
val spannable = SpannableString.valueOf(this)
val spans = spannable.getSpans<ForegroundColorSpan>()
for (span in spans) {
spannable.removeSpan(span)
}
return spannable.trim()
}
private fun getScrobbler(index: Int): Scrobbler? {
val info = scrobblingInfo.value.getOrNull(index)
val scrobbler = if (info != null) {

View File

@@ -6,6 +6,7 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.drawableEnd
import org.koitharu.kotatsu.core.util.ext.drawableStart
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.textAndVisible
@@ -47,7 +48,6 @@ fun chapterListItemAD(
}
binding.imageViewBookmarked.isVisible = item.isBookmarked
binding.imageViewDownloaded.isVisible = item.isDownloaded
// binding.imageViewNew.isVisible = item.isNew
binding.textViewTitle.drawableStart = if (item.isNew) {
ContextCompat.getDrawable(context, R.drawable.ic_new)
} else {

View File

@@ -4,7 +4,7 @@ import android.text.format.DateUtils
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaChapter
class ChapterListItem(
data class ChapterListItem(
val chapter: MangaChapter,
val flags: Int,
private val uploadDateMs: Long,
@@ -66,24 +66,6 @@ class ChapterListItem(
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ChapterListItem
if (chapter != other.chapter) return false
if (flags != other.flags) return false
return uploadDateMs == other.uploadDateMs
}
override fun hashCode(): Int {
var result = chapter.hashCode()
result = 31 * result + flags
result = 31 * result + uploadDateMs.hashCode()
return result
}
companion object {
const val FLAG_UNREAD = 2

View File

@@ -3,35 +3,14 @@ package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.parsers.model.Manga
class HistoryInfo(
data class HistoryInfo(
val totalChapters: Int,
val currentChapter: Int,
val history: MangaHistory?,
val isIncognitoMode: Boolean,
) {
val isValid: Boolean
get() = totalChapters >= 0
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as HistoryInfo
if (totalChapters != other.totalChapters) return false
if (currentChapter != other.currentChapter) return false
if (history != other.history) return false
return isIncognitoMode == other.isIncognitoMode
}
override fun hashCode(): Int {
var result = totalChapters
result = 31 * result + currentChapter
result = 31 * result + (history?.hashCode() ?: 0)
result = 31 * result + isIncognitoMode.hashCode()
return result
}
}
fun HistoryInfo(

View File

@@ -3,7 +3,7 @@ package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
class MangaBranch(
data class MangaBranch(
val name: String?,
val count: Int,
val isSelected: Boolean,
@@ -21,24 +21,6 @@ class MangaBranch(
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MangaBranch
if (name != other.name) return false
if (count != other.count) return false
return isSelected == other.isSelected
}
override fun hashCode(): Int {
var result = name.hashCode()
result = 31 * result + count
result = 31 * result + isSelected.hashCode()
return result
}
override fun toString(): String {
return "$name: $count"
}

View File

@@ -45,6 +45,6 @@ class RelatedMangaActivity : BaseActivity<ActivityContainerBinding>(), AppBarOwn
companion object {
fun newIntent(context: Context, seed: Manga) = Intent(context, RelatedMangaActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(seed, withChapters = false))
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(seed))
}
}

View File

@@ -5,7 +5,6 @@ import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.databinding.ItemScrobblingInfoBinding
@@ -37,8 +36,4 @@ fun scrobblingInfoAD(
context.resources.getStringArray(R.array.scrobbling_statuses).getOrNull(it.ordinal)
}
}
onViewRecycled {
binding.imageViewCover.disposeImageRequest()
}
}

View File

@@ -19,6 +19,7 @@ data class DownloadState(
val eta: Long = -1L,
val localManga: LocalManga? = null,
val downloadedChapters: LongArray = LongArray(0),
val scheduledChapters: LongArray = LongArray(0),
val timestamp: Long = System.currentTimeMillis(),
) {
@@ -42,6 +43,7 @@ data class DownloadState(
.putLong(DATA_TIMESTAMP, timestamp)
.putString(DATA_ERROR, error)
.putLongArray(DATA_CHAPTERS, downloadedChapters)
.putLongArray(DATA_CHAPTERS_SRC, scheduledChapters)
.putBoolean(DATA_INDETERMINATE, isIndeterminate)
.putBoolean(DATA_PAUSED, isPaused)
.build()
@@ -64,10 +66,13 @@ data class DownloadState(
if (eta != other.eta) return false
if (localManga != other.localManga) return false
if (!downloadedChapters.contentEquals(other.downloadedChapters)) return false
if (!scheduledChapters.contentEquals(other.scheduledChapters)) return false
if (timestamp != other.timestamp) return false
if (max != other.max) return false
if (progress != other.progress) return false
return percent == other.percent
if (percent != other.percent) return false
return true
}
override fun hashCode(): Int {
@@ -83,6 +88,7 @@ data class DownloadState(
result = 31 * result + eta.hashCode()
result = 31 * result + (localManga?.hashCode() ?: 0)
result = 31 * result + downloadedChapters.contentHashCode()
result = 31 * result + scheduledChapters.contentHashCode()
result = 31 * result + timestamp.hashCode()
result = 31 * result + max
result = 31 * result + progress
@@ -90,12 +96,14 @@ data class DownloadState(
return result
}
companion object {
private const val DATA_MANGA_ID = "manga_id"
private const val DATA_MAX = "max"
private const val DATA_PROGRESS = "progress"
private const val DATA_CHAPTERS = "chapter"
private const val DATA_CHAPTERS_SRC = "chapters_src"
private const val DATA_ETA = "eta"
private const val DATA_TIMESTAMP = "timestamp"
private const val DATA_ERROR = "error"
@@ -119,5 +127,7 @@ data class DownloadState(
fun getTimestamp(data: Data): Date = Date(data.getLong(DATA_TIMESTAMP, 0L))
fun getDownloadedChapters(data: Data): LongArray = data.getLongArray(DATA_CHAPTERS) ?: LongArray(0)
fun getScheduledChapters(data: Data): LongArray = data.getLongArray(DATA_CHAPTERS_SRC) ?: LongArray(0)
}
}

View File

@@ -1,19 +1,29 @@
package org.koitharu.kotatsu.download.ui.list
import android.transition.TransitionManager
import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import androidx.work.WorkInfo
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.drawableEnd
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
import org.koitharu.kotatsu.download.ui.list.chapters.downloadChapterAD
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.util.format
@@ -26,6 +36,9 @@ fun downloadItemAD(
) {
val percentPattern = context.resources.getString(R.string.percent_string_pattern)
val expandIcon = ContextCompat.getDrawable(context, R.drawable.ic_expand_collapse)
val chaptersAdapter = BaseListAdapter<DownloadChapter>()
.addDelegate(ListItemType.CHAPTER, downloadChapterAD())
val clickListener = object : View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View) {
@@ -46,8 +59,13 @@ fun downloadItemAD(
binding.buttonResume.setOnClickListener(clickListener)
itemView.setOnClickListener(clickListener)
itemView.setOnLongClickListener(clickListener)
binding.recyclerViewChapters.addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL))
binding.recyclerViewChapters.adapter = chaptersAdapter
bind { payloads ->
if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads && context.isAnimationsEnabled) {
TransitionManager.beginDelayedTransition(binding.constraintLayout)
}
binding.textViewTitle.text = item.manga.title
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.apply {
placeholder(R.drawable.ic_placeholder)
@@ -58,6 +76,10 @@ fun downloadItemAD(
source(item.manga.source)
enqueueWith(coil)
}
binding.textViewTitle.isChecked = item.isExpanded
binding.textViewTitle.drawableEnd = if (item.isExpandable) expandIcon else null
binding.cardDetails.isVisible = item.isExpanded
chaptersAdapter.items = item.chapters
when (item.workState) {
WorkInfo.State.ENQUEUED,
WorkInfo.State.BLOCKED -> {
@@ -135,8 +157,4 @@ fun downloadItemAD(
}
}
}
onViewRecycled {
binding.imageViewCover.disposeImageRequest()
}
}

View File

@@ -2,12 +2,14 @@ package org.koitharu.kotatsu.download.ui.list
import android.text.format.DateUtils
import androidx.work.WorkInfo
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date
import java.util.UUID
class DownloadItemModel(
data class DownloadItemModel(
val id: UUID,
val workState: WorkInfo.State,
val isIndeterminate: Boolean,
@@ -19,6 +21,8 @@ class DownloadItemModel(
val progress: Int,
val eta: Long,
val timestamp: Date,
val chapters: List<DownloadChapter>,
val isExpanded: Boolean,
) : ListModel, Comparable<DownloadItemModel> {
val percent: Float
@@ -33,6 +37,9 @@ class DownloadItemModel(
val canResume: Boolean
get() = workState == WorkInfo.State.RUNNING && isPaused
val isExpandable: Boolean
get() = chapters.isNotEmpty()
fun getEtaString(): CharSequence? = if (hasEta) {
DateUtils.getRelativeTimeSpanString(
eta,
@@ -51,51 +58,10 @@ class DownloadItemModel(
return other is DownloadItemModel && other.id == id
}
override fun getChangePayload(previousState: ListModel): Any? {
return when (previousState) {
is DownloadItemModel -> {
if (workState == previousState.workState) {
Unit
} else {
null
}
}
else -> super.getChangePayload(previousState)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DownloadItemModel
if (id != other.id) return false
if (workState != other.workState) return false
if (isIndeterminate != other.isIndeterminate) return false
if (isPaused != other.isPaused) return false
if (manga != other.manga) return false
if (error != other.error) return false
if (max != other.max) return false
if (totalChapters != other.totalChapters) return false
if (progress != other.progress) return false
if (eta != other.eta) return false
return timestamp == other.timestamp
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + workState.hashCode()
result = 31 * result + isIndeterminate.hashCode()
result = 31 * result + isPaused.hashCode()
result = 31 * result + manga.hashCode()
result = 31 * result + (error?.hashCode() ?: 0)
result = 31 * result + max
result = 31 * result + totalChapters
result = 31 * result + progress
result = 31 * result + eta.hashCode()
result = 31 * result + timestamp.hashCode()
return result
override fun getChangePayload(previousState: ListModel): Any? = when {
previousState !is DownloadItemModel -> super.getChangePayload(previousState)
workState != previousState.workState -> null
isExpanded != previousState.isExpanded -> ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
else -> ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED
}
}

View File

@@ -82,7 +82,11 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
if (selectionController.onItemClick(item.id.mostSignificantBits)) {
return
}
startActivity(DetailsActivity.newIntent(view.context, item.manga))
if (item.isExpandable) {
viewModel.expandCollapse(item)
} else {
startActivity(DetailsActivity.newIntent(view.context, item.manga))
}
}
override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean {

View File

@@ -8,15 +8,19 @@ import androidx.work.Data
import androidx.work.WorkInfo
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
@@ -24,6 +28,7 @@ import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.daysDiff
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader
@@ -31,6 +36,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Date
import java.util.LinkedList
import java.util.UUID
@@ -41,13 +47,18 @@ import javax.inject.Inject
class DownloadsViewModel @Inject constructor(
private val workScheduler: DownloadWorker.Scheduler,
private val mangaDataRepository: MangaDataRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
) : BaseViewModel() {
private val mangaCache = LongSparseArray<Manga>()
private val cacheMutex = Mutex()
private val works = workScheduler.observeWorks()
.mapLatest { it.toDownloadsList() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
private val expanded = MutableStateFlow(emptySet<UUID>())
private val works = combine(
workScheduler.observeWorks(),
expanded,
) { list, exp ->
list.toDownloadsList(exp)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val onActionDone = MutableEventFlow<ReversibleAction>()
@@ -169,11 +180,21 @@ class DownloadsViewModel @Inject constructor(
it.id.mostSignificantBits
} ?: emptySet()
private suspend fun List<WorkInfo>.toDownloadsList(): List<DownloadItemModel> {
fun expandCollapse(item: DownloadItemModel) {
expanded.update {
if (item.id in it) {
it - item.id
} else {
it + item.id
}
}
}
private suspend fun List<WorkInfo>.toDownloadsList(exp: Set<UUID>): List<DownloadItemModel> {
if (isEmpty()) {
return emptyList()
}
val list = mapNotNullTo(ArrayList(size)) { it.toUiModel() }
val list = mapNotNullTo(ArrayList(size)) { it.toUiModel(it.id in exp) }
list.sortByDescending { it.timestamp }
return list
}
@@ -213,11 +234,13 @@ class DownloadsViewModel @Inject constructor(
return destination
}
private suspend fun WorkInfo.toUiModel(): DownloadItemModel? {
private suspend fun WorkInfo.toUiModel(isExpanded: Boolean): DownloadItemModel? {
val workData = if (outputData == Data.EMPTY) progress else outputData
val mangaId = DownloadState.getMangaId(workData)
if (mangaId == 0L) return null
val manga = getManga(mangaId) ?: return null
val downloadedChapters = DownloadState.getDownloadedChapters(workData)
val scheduledChapters = DownloadState.getScheduledChapters(workData).toSet()
return DownloadItemModel(
id = id,
workState = state,
@@ -229,7 +252,19 @@ class DownloadsViewModel @Inject constructor(
progress = DownloadState.getProgress(workData),
eta = DownloadState.getEta(workData),
timestamp = DownloadState.getTimestamp(workData),
totalChapters = DownloadState.getDownloadedChapters(workData).size,
totalChapters = downloadedChapters.size,
isExpanded = isExpanded,
chapters = manga.chapters?.mapNotNull {
if (it.id in scheduledChapters) {
DownloadChapter(
number = it.number,
name = it.name,
isDownloaded = it.id in downloadedChapters,
)
} else {
null
}
}.orEmpty(),
)
}
@@ -261,8 +296,16 @@ class DownloadsViewModel @Inject constructor(
}
return cacheMutex.withLock {
mangaCache.getOrElse(mangaId) {
mangaDataRepository.findMangaById(mangaId)?.also { mangaCache[mangaId] = it } ?: return null
mangaDataRepository.findMangaById(mangaId)?.let {
tryLoad(it) ?: it
}?.also {
mangaCache[mangaId] = it
} ?: return null
}
}
}
private suspend fun tryLoad(manga: Manga) = runCatchingCancellable {
(mangaRepositoryFactory.create(manga.source) as RemoteMangaRepository).peekDetails(manga)
}.getOrNull()
}

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.download.ui.list.chapters
import org.koitharu.kotatsu.list.ui.model.ListModel
data class DownloadChapter(
val number: Int,
val name: String,
val isDownloaded: Boolean,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is DownloadChapter && other.name == name
}
}

View File

@@ -0,0 +1,20 @@
package org.koitharu.kotatsu.download.ui.list.chapters
import androidx.core.content.ContextCompat
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.drawableEnd
import org.koitharu.kotatsu.databinding.ItemChapterDownloadBinding
fun downloadChapterAD() = adapterDelegateViewBinding<DownloadChapter, DownloadChapter, ItemChapterDownloadBinding>(
{ layoutInflater, parent -> ItemChapterDownloadBinding.inflate(layoutInflater, parent, false) },
) {
val iconDone = ContextCompat.getDrawable(context, R.drawable.ic_check)
bind {
binding.textViewNumber.text = item.number.toString()
binding.textViewTitle.text = item.name
binding.textViewTitle.drawableEnd = if (item.isDownloaded) iconDone else null
}
}

View File

@@ -37,6 +37,7 @@ import okio.IOException
import okio.buffer
import okio.sink
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.parser.MangaDataRepository
@@ -177,6 +178,9 @@ class DownloadWorker @AssistedInject constructor(
}
}
val chapters = getChapters(mangaDetails, includedIds)
publishState(
currentState.copy(scheduledChapters = LongArray(chapters.size) { i -> chapters[i].id }),
)
for ((chapterIndex, chapter) in chapters.withIndex()) {
if (chaptersToSkip.remove(chapter.id)) {
publishState(
@@ -277,7 +281,12 @@ class DownloadWorker @AssistedInject constructor(
publishState(currentState.copy(isPaused = false, error = null))
} else {
countDown--
delay(DOWNLOAD_ERROR_DELAY)
val retryDelay = if (e is TooManyRequestExceptions) {
e.retryAfter + DOWNLOAD_ERROR_DELAY
} else {
DOWNLOAD_ERROR_DELAY
}
delay(retryDelay)
}
}
}

View File

@@ -3,20 +3,22 @@ package org.koitharu.kotatsu.explore.data
import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.move
import java.util.Collections
import java.util.EnumSet
import javax.inject.Inject
@@ -76,14 +78,6 @@ class MangaSourcesRepository @Inject constructor(
}
}
suspend fun setSourcesEnabled(sources: Iterable<MangaSource>, isEnabled: Boolean) {
db.withTransaction {
for (s in sources) {
dao.setEnabled(s.name, isEnabled)
}
}
}
suspend fun disableAllSources() {
db.withTransaction {
assimilateNewSources()
@@ -99,46 +93,26 @@ class MangaSourcesRepository @Inject constructor(
}
}
suspend fun setPosition(source: MangaSource, index: Int) {
db.withTransaction {
val all = dao.findAll().toMutableList()
val sourceIndex = all.indexOfFirst { x -> x.source == source.name }
if (sourceIndex !in all.indices) {
val entity = MangaSourceEntity(
source = source.name,
isEnabled = false,
sortKey = index,
)
all.add(index, entity)
dao.upsert(entity)
} else {
all.move(sourceIndex, index)
}
for ((i, e) in all.withIndex()) {
if (e.sortKey != i) {
dao.setSortKey(e.source, i)
fun observeNewSources(): Flow<Set<MangaSource>> = observeIsNewSourcesEnabled().flatMapLatest {
if (it) {
combine(
dao.observeAll(),
observeIsNsfwDisabled(),
) { entities, skipNsfw ->
val result = EnumSet.copyOf(remoteSources)
for (e in entities) {
result.remove(MangaSource(e.source))
}
}
if (skipNsfw) {
result.removeAll { x -> x.isNsfw() }
}
result
}.distinctUntilChanged()
} else {
flowOf(emptySet())
}
}
fun observeNewSources(): Flow<Set<MangaSource>> = dao.observeAll().map { entities ->
val result = EnumSet.copyOf(remoteSources)
for (e in entities) {
result.remove(MangaSource(e.source))
}
result
}.distinctUntilChanged()
suspend fun getNewSources(): Set<MangaSource> {
val entities = dao.findAll()
val result = EnumSet.copyOf(remoteSources)
for (e in entities) {
result.remove(MangaSource(e.source))
}
return result
}
suspend fun assimilateNewSources(): Set<MangaSource> {
val new = getNewSources()
if (new.isEmpty()) {
@@ -153,6 +127,9 @@ class MangaSourcesRepository @Inject constructor(
)
}
dao.insertIfAbsent(entities)
if (settings.isNsfwContentDisabled) {
new.removeAll { x -> x.isNsfw() }
}
return new
}
@@ -160,6 +137,15 @@ class MangaSourcesRepository @Inject constructor(
return dao.findAll().isEmpty()
}
private suspend fun getNewSources(): MutableSet<MangaSource> {
val entities = dao.findAll()
val result = EnumSet.copyOf(remoteSources)
for (e in entities) {
result.remove(MangaSource(e.source))
}
return result
}
private fun List<MangaSourceEntity>.toSources(skipNsfwSources: Boolean): List<MangaSource> {
val result = ArrayList<MangaSource>(size)
for (entity in this) {
@@ -177,4 +163,8 @@ class MangaSourcesRepository @Inject constructor(
private fun observeIsNsfwDisabled() = settings.observeAsFlow(AppSettings.KEY_DISABLE_NSFW) {
isNsfwContentDisabled
}
private fun observeIsNewSourcesEnabled() = settings.observeAsFlow(AppSettings.KEY_SOURCES_NEW) {
isNewSourcesTipEnabled
}
}

View File

@@ -7,6 +7,7 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
@@ -15,9 +16,11 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
@@ -28,6 +31,7 @@ import org.koitharu.kotatsu.core.ui.widgets.TipView
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.databinding.FragmentExploreBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
@@ -55,6 +59,9 @@ class ExploreFragment :
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var shortcutManager: AppShortcutManager
private val viewModel by viewModels<ExploreViewModel>()
private var exploreAdapter: ExploreAdapter? = null
@@ -141,6 +148,8 @@ class ExploreFragment :
override fun onItemLongClick(item: MangaSourceItem, view: View): Boolean {
val menu = PopupMenu(view.context, view)
menu.inflate(R.menu.popup_source)
menu.menu.findItem(R.id.action_shortcut)
?.isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(view.context)
menu.setOnMenuItemClickListener(SourceMenuListener(item))
menu.show()
return true
@@ -195,6 +204,12 @@ class ExploreFragment :
viewModel.hideSource(sourceItem.source)
}
R.id.action_shortcut -> {
viewLifecycleScope.launch {
shortcutManager.requestPinShortcut(sourceItem.source)
}
}
else -> return false
}
return true

View File

@@ -12,11 +12,11 @@ import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding
@@ -45,7 +45,12 @@ fun exploreButtonsAD(
if (item.isRandomLoading) {
val icon = CircularProgressDrawable(context)
icon.strokeWidth = context.resources.resolveDp(2f)
icon.setColorSchemeColors(context.getThemeColor(materialR.attr.colorPrimary, Color.DKGRAY))
icon.setColorSchemeColors(
context.getThemeColor(
materialR.attr.colorPrimary,
Color.DKGRAY
)
)
binding.buttonRandom.icon = icon
icon.start()
} else {
@@ -82,10 +87,6 @@ fun exploreRecommendationItemAD(
enqueueWith(coil)
}
}
onViewRecycled {
binding.imageViewCover.disposeImageRequest()
}
}
fun exploreSourceListItemAD(
@@ -93,7 +94,13 @@ fun exploreSourceListItemAD(
listener: OnListItemClickListener<MangaSourceItem>,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<MangaSourceItem, ListModel, ItemExploreSourceListBinding>(
{ layoutInflater, parent -> ItemExploreSourceListBinding.inflate(layoutInflater, parent, false) },
{ layoutInflater, parent ->
ItemExploreSourceListBinding.inflate(
layoutInflater,
parent,
false
)
},
on = { item, _, _ -> item is MangaSourceItem && !item.isGrid },
) {
@@ -101,6 +108,7 @@ fun exploreSourceListItemAD(
binding.root.setOnClickListener(eventListener)
binding.root.setOnLongClickListener(eventListener)
binding.root.setOnContextClickListenerCompat(eventListener)
bind {
binding.textViewTitle.text = item.source.title
@@ -113,10 +121,6 @@ fun exploreSourceListItemAD(
enqueueWith(coil)
}
}
onViewRecycled {
binding.imageViewIcon.disposeImageRequest()
}
}
fun exploreSourceGridItemAD(
@@ -124,7 +128,13 @@ fun exploreSourceGridItemAD(
listener: OnListItemClickListener<MangaSourceItem>,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<MangaSourceItem, ListModel, ItemExploreSourceGridBinding>(
{ layoutInflater, parent -> ItemExploreSourceGridBinding.inflate(layoutInflater, parent, false) },
{ layoutInflater, parent ->
ItemExploreSourceGridBinding.inflate(
layoutInflater,
parent,
false
)
},
on = { item, _, _ -> item is MangaSourceItem && item.isGrid },
) {
@@ -132,6 +142,7 @@ fun exploreSourceGridItemAD(
binding.root.setOnClickListener(eventListener)
binding.root.setOnLongClickListener(eventListener)
binding.root.setOnContextClickListenerCompat(eventListener)
bind {
binding.textViewTitle.text = item.source.title
@@ -144,8 +155,4 @@ fun exploreSourceGridItemAD(
enqueueWith(coil)
}
}
onViewRecycled {
binding.imageViewIcon.disposeImageRequest()
}
}

View File

@@ -2,24 +2,11 @@ package org.koitharu.kotatsu.explore.ui.model
import org.koitharu.kotatsu.list.ui.model.ListModel
class ExploreButtons(
data class ExploreButtons(
val isRandomLoading: Boolean,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is ExploreButtons
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ExploreButtons
return isRandomLoading == other.isRandomLoading
}
override fun hashCode(): Int {
return isRandomLoading.hashCode()
}
}

View File

@@ -3,7 +3,7 @@ package org.koitharu.kotatsu.explore.ui.model
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaSource
class MangaSourceItem(
data class MangaSourceItem(
val source: MangaSource,
val isGrid: Boolean,
) : ListModel {
@@ -11,20 +11,4 @@ class MangaSourceItem(
override fun areItemsTheSame(other: ListModel): Boolean {
return other is MangaSourceItem && other.source == source
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MangaSourceItem
if (source != other.source) return false
return isGrid == other.isGrid
}
override fun hashCode(): Int {
var result = source.hashCode()
result = 31 * result + isGrid.hashCode()
return result
}
}

View File

@@ -6,7 +6,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
data class RecommendationsItem(
val manga: Manga
) : ListModel {
val summary: String = manga.tags.joinToString { it.title }
override fun areItemsTheSame(other: ListModel): Boolean {

View File

@@ -1,17 +1,16 @@
package org.koitharu.kotatsu.favourites.data
import org.koitharu.kotatsu.core.db.entity.SortOrder
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.parsers.model.SortOrder
import org.koitharu.kotatsu.list.domain.ListSortOrder
import java.util.Date
fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory(
id = id,
title = title,
sortKey = sortKey,
order = SortOrder(order, SortOrder.NEWEST),
order = ListSortOrder(order, ListSortOrder.NEWEST),
createdAt = Date(createdAt),
isTrackingEnabled = track,
isVisibleInLibrary = isVisibleInLibrary,

View File

@@ -1,13 +1,19 @@
package org.koitharu.kotatsu.favourites.data
import androidx.room.*
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction
import androidx.room.Upsert
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.list.domain.ListSortOrder
@Dao
abstract class FavouritesDao {
@@ -22,7 +28,7 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit")
abstract suspend fun findLast(limit: Int): List<FavouriteManga>
fun observeAll(order: SortOrder): Flow<List<FavouriteManga>> {
fun observeAll(order: ListSortOrder): Flow<List<FavouriteManga>> {
val orderBy = getOrderBy(order)
@Language("RoomSql")
@@ -47,7 +53,7 @@ abstract class FavouritesDao {
)
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
fun observeAll(categoryId: Long, order: SortOrder): Flow<List<FavouriteManga>> {
fun observeAll(categoryId: Long, order: ListSortOrder): Flow<List<FavouriteManga>> {
val orderBy = getOrderBy(order)
@Language("RoomSql")
@@ -72,13 +78,14 @@ abstract class FavouritesDao {
)
abstract suspend fun findAllManga(categoryId: Int): List<MangaEntity>
suspend fun findCovers(categoryId: Long, order: SortOrder): List<Cover> {
suspend fun findCovers(categoryId: Long, order: ListSortOrder): List<Cover> {
val orderBy = getOrderBy(order)
@Language("RoomSql")
val query = SimpleSQLiteQuery(
"SELECT m.cover_url AS url, m.source AS source FROM favourites AS f LEFT JOIN manga AS m ON f.manga_id = m.manga_id " +
"WHERE f.category_id = ? AND deleted_at = 0 ORDER BY $orderBy",
"SELECT manga.cover_url AS url, manga.source AS source FROM favourites " +
"LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
"WHERE favourites.category_id = ? AND deleted_at = 0 ORDER BY $orderBy",
arrayOf<Any>(categoryId),
)
return findCoversImpl(query)
@@ -157,13 +164,12 @@ abstract class FavouritesDao {
@Query("UPDATE favourites SET deleted_at = :deletedAt WHERE category_id = :categoryId AND deleted_at = 0")
protected abstract suspend fun setDeletedAtAll(categoryId: Long, deletedAt: Long)
private fun getOrderBy(sortOrder: SortOrder) = when (sortOrder) {
SortOrder.RATING -> "rating DESC"
SortOrder.NEWEST,
SortOrder.UPDATED,
-> "created_at DESC"
SortOrder.ALPHABETICAL -> "title ASC"
private fun getOrderBy(sortOrder: ListSortOrder) = when (sortOrder) {
ListSortOrder.RATING -> "manga.rating DESC"
ListSortOrder.NEWEST -> "favourites.created_at DESC"
ListSortOrder.ALPHABETIC -> "manga.title ASC"
ListSortOrder.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = manga.manga_id) DESC"
ListSortOrder.PROGRESS -> "(SELECT percent FROM history WHERE history.manga_id = manga.manga_id) DESC"
else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
}
}

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