Compare commits

...

154 Commits
v6.1.5 ... v6.4

Author SHA1 Message Date
Koitharu
012eefe4fe Fix NPE in ListConfigViewModel 2023-11-25 17:40:54 +02:00
Oliullah
cb0f0c70d0 Translated using Weblate (Bengali)
Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: Oliullah <shahin1465686@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/bn/
Translation: Kotatsu/plurals
2023-11-25 17:34:07 +02:00
Макар Разин
23111dfef9 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (525 of 525 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (525 of 525 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (525 of 525 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-11-25 17:34:07 +02:00
gallegonovato
d050c9ad0e Translated using Weblate (Spanish)
Currently translated at 100.0% (525 of 525 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (524 of 524 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-11-25 17:34:07 +02:00
Koitharu
efd952a91a Localize parsers errors 2023-11-25 17:33:05 +02:00
Koitharu
d3f23ea3a3 Add manga state to filter 2023-11-25 17:25:48 +02:00
Koitharu
acba312e8d Misc fixes 2023-11-25 09:18:08 +02:00
Koitharu
880dd6da27 Load local manga pages directly #552 2023-11-24 18:54:09 +02:00
Koitharu
0c839ce49a Fix locale selection in sources catalog #561 2023-11-24 17:10:58 +02:00
Isira Seneviratne
1afd2d3976 Use file walking APIs 2023-11-24 16:59:01 +02:00
Koitharu
f2d881f9bc Fix crash with locales sorting 2023-11-24 16:58:11 +02:00
Koitharu
c838e57f22 Downsample offscreen pages option 2023-11-24 16:53:44 +02:00
Koitharu
2075b1be19 Add lifecycle for BasePageHolder 2023-11-24 14:59:12 +02:00
Koitharu
cf33cb66c6 Fix release build 2023-11-23 13:25:12 +02:00
Макар Разин
8010c5079b Translated using Weblate (Ukrainian)
Currently translated at 100.0% (524 of 524 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (524 of 524 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (524 of 524 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-11-23 12:47:59 +02:00
Koitharu
a87a77083e Reader fixes 2023-11-23 12:42:43 +02:00
Koitharu
ca20422344 Support Paused manga state 2023-11-23 12:33:48 +02:00
Koitharu
c213b9d4b5 Update parsers 2023-11-23 12:26:38 +02:00
Koitharu
95fbe496cb Search through all sources in catalog 2023-11-22 16:11:23 +02:00
Koitharu
b9fd2e100d Fallback to local manga on network error 2023-11-22 15:38:39 +02:00
Koitharu
1242a88f8e Fix disabling webtoon scale 2023-11-22 15:10:26 +02:00
Koitharu
55851fb22f Avoid replacing online manga wthin local in database 2023-11-18 16:03:01 +02:00
Koitharu
7801456d17 Enable desugaring to fit Jsoup requirements #553 2023-11-18 15:12:18 +02:00
Koitharu
38a1fafa26 Load local manga if not connection when possible #547 2023-11-18 13:35:12 +02:00
Koitharu
aa02233883 Update parsers 2023-11-18 13:35:12 +02:00
Abay Emes
5405fdb85a Translated using Weblate (Kazakh)
Currently translated at 100.0% (524 of 524 strings)

Co-authored-by: Abay Emes <abayemes@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/kk/
Translation: Kotatsu/Strings
2023-11-18 13:35:03 +02:00
InfinityDouki56
38ad7e1fd4 Translated using Weblate (Filipino)
Currently translated at 88.3% (463 of 524 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-11-18 12:31:21 +02:00
gallegonovato
06372083fd Translated using Weblate (Spanish)
Currently translated at 100.0% (524 of 524 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-11-18 12:31:21 +02:00
Isira Seneviratne
d5d3154074 Avoid accidental link clicks 2023-11-18 12:30:39 +02:00
Koitharu
1a279966d9 Update parsers 2023-11-14 07:54:03 +02:00
Koitharu
3222c2128e Translated using Weblate (Russian)
Currently translated at 99.8% (523 of 524 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-11-14 07:45:12 +02:00
gallegonovato
872c859efe Translated using Weblate (Spanish)
Currently translated at 100.0% (521 of 521 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (519 of 519 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (510 of 510 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-11-14 07:45:12 +02:00
Макар Разин
b79c00f8df Translated using Weblate (Ukrainian)
Currently translated at 100.0% (510 of 510 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (510 of 510 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (510 of 510 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-11-14 07:45:12 +02:00
Koitharu
e7d3d9811d Fix reader zoom buttons 2023-11-12 17:45:37 +02:00
Koitharu
4fdfc75833 Try fix strange crashes 2023-11-12 16:57:05 +02:00
Koitharu
9754ebf1bb Reduce main menu while search opened 2023-11-12 16:48:18 +02:00
Koitharu
fee35cceab Sources settings screen 2023-11-12 16:30:11 +02:00
Koitharu
b928c4123c Update explore navigation 2023-11-12 13:16:42 +02:00
Koitharu
b093a885c9 Sources catalog 2023-11-12 12:59:12 +02:00
Koitharu
dd898579c9 Option to lock reader screen rotation 2023-11-11 15:01:08 +02:00
Koitharu
73143d2f94 Rework favourite sheet 2023-11-11 14:40:30 +02:00
Koitharu
563752f6a4 Upgrade gradle 2023-11-11 12:59:16 +02:00
Koitharu
7135902100 Update parsers 2023-11-10 14:55:28 +02:00
Nayuki
969947ef71 Translated using Weblate (Thai)
Currently translated at 73.2% (373 of 509 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2023-11-10 14:48:38 +02:00
GpixeL
806e4eade6 Translated using Weblate (Indonesian)
Currently translated at 99.4% (506 of 509 strings)

Co-authored-by: GpixeL <gamesfire313@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-11-10 14:48:38 +02:00
Abay Emes
063cfbe6b9 Translated using Weblate (Kazakh)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Kazakh)

Currently translated at 100.0% (509 of 509 strings)

Co-authored-by: Abay Emes <abayemes@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/kk/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/kk/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-11-10 14:48:38 +02:00
InfinityDouki56
7cb94a3baa Translated using Weblate (Filipino)
Currently translated at 88.8% (452 of 509 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-11-10 14:48:38 +02:00
Oğuz Ersen
894c584c78 Translated using Weblate (Turkish)
Currently translated at 100.0% (509 of 509 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-11-10 14:48:38 +02:00
gallegonovato
2f65e7776a Translated using Weblate (Spanish)
Currently translated at 100.0% (509 of 509 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-11-10 14:48:38 +02:00
Макар Разин
76c56c9119 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (509 of 509 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (509 of 509 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (509 of 509 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-11-10 14:48:38 +02:00
InfinityDouki56
e0a803399c Translated using Weblate (Filipino)
Currently translated at 88.9% (452 of 508 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-11-07 18:49:34 +02:00
kenewjr
7803f42486 Translated using Weblate (Indonesian)
Currently translated at 96.4% (490 of 508 strings)

Co-authored-by: kenewjr <kenelewatan@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-11-07 18:49:34 +02:00
Макар Разин
39713b3cf6 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (508 of 508 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-11-07 18:49:34 +02:00
Nayuki
8ebf5cea62 Translated using Weblate (Thai)
Currently translated at 68.7% (349 of 508 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2023-11-07 18:49:34 +02:00
Abay Emes
663dabe218 Added translation using Weblate (Kazakh)
Translated using Weblate (Kazakh)

Currently translated at 57.4% (292 of 508 strings)

Co-authored-by: Abay Emes <abayemes@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/kk/
Translation: Kotatsu/Strings
2023-11-07 18:49:34 +02:00
Tommy12pl
3a5d0120bf Translated using Weblate (Chinese (Simplified))
Currently translated at 99.4% (505 of 508 strings)

Co-authored-by: Tommy12pl <tommy12pl@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-11-07 18:49:34 +02:00
gallegonovato
a773f932d4 Translated using Weblate (Spanish)
Currently translated at 100.0% (508 of 508 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-11-07 18:49:34 +02:00
Koitharu
2a5812735f Cubic reader scroll speed 2023-11-05 08:54:07 +02:00
Koitharu
06ec145802 Update parsers 2023-11-02 08:56:43 +02:00
Koitharu
6624778f7f Fix periodical backups 2023-11-02 08:50:51 +02:00
Koitharu
1af1f071ad Fix crashes 2023-11-01 17:25:55 +02:00
Koitharu
f87db4e6d3 Update dependencies 2023-11-01 16:38:10 +02:00
Crono
07bd66fb39 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (508 of 508 strings)

Co-authored-by: Crono <cronoreader@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2023-11-01 16:32:38 +02:00
Koitharu
4bb0d52217 Fix downloading 2023-10-28 16:39:43 +03:00
Koitharu
66de4bd49e Translated using Weblate (Russian)
Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (507 of 507 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
Bai
ff12d63696 Translated using Weblate (Turkish)
Currently translated at 100.0% (507 of 507 strings)

Co-authored-by: Bai <batuhanakkurt000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
InfinityDouki56
c168a841f3 Translated using Weblate (Filipino)
Currently translated at 88.9% (451 of 507 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
pro maxime
8bfb676e6a Translated using Weblate (Arabic)
Currently translated at 36.0% (183 of 507 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: pro maxime <promaxime45@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-10-28 16:16:39 +03:00
gallegonovato
d5c0ce280e Translated using Weblate (Spanish)
Currently translated at 100.0% (507 of 507 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
Vinícius Saturnino
b34627c361 Translated using Weblate (Portuguese)
Currently translated at 100.0% (498 of 498 strings)

Co-authored-by: Vinícius Saturnino <saturninodepaulavinicius62@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
Paulo Oliveira
cbc3be056a Translated using Weblate (Portuguese)
Currently translated at 100.0% (498 of 498 strings)

Co-authored-by: Paulo Oliveira <junior.literasas@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
Koitharu
d9acc4ec18 Fix periodical backups to external directory 2023-10-28 16:14:47 +03:00
Koitharu
577cc848ee Scroll lists to top atomatically 2023-10-28 15:26:22 +03:00
Koitharu
8a64c88a07 (Temporary) remove chapters list from downloads 2023-10-28 14:44:58 +03:00
Koitharu
1cd7745e38 Update parsers 2023-10-28 13:26:02 +03:00
Koitharu
395b3f7200 Fix proguard rules 2023-10-27 17:27:40 +03:00
Koitharu
b8db4c81d8 Handle up navigation from reader 2023-10-27 16:44:40 +03:00
Koitharu
98bd42f3ae Remove deletions from sync process 2023-10-27 15:02:10 +03:00
Koitharu
db8835a7b8 Fix history restoring 2023-10-27 14:18:14 +03:00
Koitharu
afe50a9ed6 Fixes 2023-10-27 13:58:04 +03:00
Koitharu
beba818f57 Periodic backups 2023-10-26 17:24:11 +03:00
Koitharu
beb17ef442 Pause autoscroll while touch down 2023-10-26 16:13:30 +03:00
Koitharu
24f1546019 Fix pagination 2023-10-26 12:45:32 +03:00
ngocanhtve
1b0fed5c56 Translated using Weblate (Vietnamese)
Currently translated at 84.1% (419 of 498 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: ngocanhtve <ngocanh.tve@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/vi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-10-26 12:29:29 +03:00
Koitharu
3d32bd9d58 Fix warnings 2023-10-25 15:42:00 +03:00
Koitharu
590120433c Update dependencies 2023-10-25 15:42:00 +03:00
Koitharu
4bd7656681 Fix loading footer in lists 2023-10-25 15:41:59 +03:00
Koitharu
2c7438e64d Add error reporting to import local manga 2023-10-25 15:41:59 +03:00
InfinityDouki56
665bebaa7b Translated using Weblate (Filipino)
Currently translated at 88.9% (443 of 498 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-10-25 12:05:52 +03:00
return_null
6ed5994726 Translated using Weblate (Chinese (Simplified))
Currently translated at 98.5% (491 of 498 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-25 12:05:52 +03:00
Dpper
311ed865b7 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (498 of 498 strings)

Co-authored-by: Dpper <ruslan20020401@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-10-25 12:05:52 +03:00
Bai
b59fb678fe Translated using Weblate (Turkish)
Currently translated at 100.0% (498 of 498 strings)

Co-authored-by: Bai <batuhanakkurt000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-10-25 12:05:52 +03:00
Koitharu
ed9ebdcc55 Handle kotatsu scheme links 2023-10-23 17:20:44 +03:00
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
317 changed files with 5573 additions and 2291 deletions

View File

@@ -16,11 +16,12 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 583 versionCode = 600
versionName = '6.1.5' versionName = '6.4'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner" testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
ksp { ksp {
// arg("room.generateKotlin", "true") TODO: enable later
arg("room.schemaLocation", "$projectDir/schemas") arg("room.schemaLocation", "$projectDir/schemas")
} }
androidResources { androidResources {
@@ -32,7 +33,6 @@ android {
applicationIdSuffix = '.debug' applicationIdSuffix = '.debug'
} }
release { release {
multiDexEnabled false
minifyEnabled true minifyEnabled true
shrinkResources true shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
@@ -47,11 +47,12 @@ android {
main.java.srcDirs += 'src/main/kotlin/' main.java.srcDirs += 'src/main/kotlin/'
} }
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_17 coreLibraryDesugaringEnabled true
targetCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
} }
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString() jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += [ freeCompilerArgs += [
'-opt-in=kotlin.ExperimentalStdlibApi', '-opt-in=kotlin.ExperimentalStdlibApi',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
@@ -81,28 +82,29 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:931f126119') { implementation('com.github.KotatsuApp:kotatsu-parsers:46e863ef79') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.10' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.20'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.activity:activity-ktx:1.8.0-rc01' implementation 'androidx.activity:activity-ktx:1.8.1'
implementation 'androidx.fragment:fragment-ktx:1.6.1' implementation 'androidx.fragment:fragment-ktx:1.6.2'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-livedata-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-service:2.6.2'
implementation 'androidx.lifecycle:lifecycle-process:2.6.2' implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.1' implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.9.0' implementation 'com.google.android.material:material:1.10.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.2' implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.2'
// TODO https://issuetracker.google.com/issues/254846063 // TODO https://issuetracker.google.com/issues/254846063
@@ -114,35 +116,35 @@ dependencies {
exclude group: 'com.google.j2objc', module: 'j2objc-annotations' exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
} }
implementation 'androidx.room:room-runtime:2.5.2' implementation 'androidx.room:room-runtime:2.6.0'
implementation 'androidx.room:room-ktx:2.5.2' implementation 'androidx.room:room-ktx:2.6.0'
ksp 'androidx.room:room-compiler:2.5.2' ksp 'androidx.room:room-compiler:2.6.0'
implementation 'com.squareup.okhttp3:okhttp:4.11.0' implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
implementation 'com.squareup.okio:okio:3.6.0' implementation 'com.squareup.okio:okio:3.6.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'com.google.dagger:hilt-android:2.48' implementation 'com.google.dagger:hilt-android:2.48.1'
kapt 'com.google.dagger:hilt-compiler:2.48' kapt 'com.google.dagger:hilt-compiler:2.48.1'
implementation 'androidx.hilt:hilt-work:1.0.0' implementation 'androidx.hilt:hilt-work:1.1.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0' kapt 'androidx.hilt:hilt-compiler:1.1.0'
implementation 'io.coil-kt:coil-base:2.4.0' implementation 'io.coil-kt:coil-base:2.5.0'
implementation 'io.coil-kt:coil-svg:2.4.0' implementation 'io.coil-kt:coil-svg:2.5.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:169806d928' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:c7dab3aefe'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2' implementation 'io.noties.markwon:core:4.6.2'
implementation 'ch.acra:acra-http:5.11.2' implementation 'ch.acra:acra-http:5.11.3'
implementation 'ch.acra:acra-dialog:5.11.2' implementation 'ch.acra:acra-dialog:5.11.3'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20230618' testImplementation 'org.json:json:20231013'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:runner:1.5.2'
@@ -152,9 +154,9 @@ dependencies {
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
androidTestImplementation 'androidx.room:room-testing:2.5.2' androidTestImplementation 'androidx.room:room-testing:2.6.0'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0' androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.48' androidTestImplementation 'com.google.dagger:hilt-android-testing:2.48.1'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.48' kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.48.1'
} }

View File

@@ -18,3 +18,6 @@
-keep class org.koitharu.kotatsu.core.exceptions.* { *; } -keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment -keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; } -keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
-keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }
-keep class org.jsoup.parser.Tag
-keep class org.jsoup.internal.StringUtil

View File

@@ -82,7 +82,7 @@ class AppBackupAgentTest {
assertEquals(history, historyRepository.getOne(SampleData.manga)) assertEquals(history, historyRepository.getOne(SampleData.manga))
assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id)) assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id))
val allTags = database.tagsDao.findTags(SampleData.tag.source.name).toMangaTags() val allTags = database.getTagsDao().findTags(SampleData.tag.source.name).toMangaTags()
assertTrue(SampleData.tag in allTags) assertTrue(SampleData.tag in allTags)
} }

View File

@@ -5,6 +5,7 @@ import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
@@ -19,19 +20,14 @@ class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSourc
override val configKeyDomain: ConfigKey.Domain override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("") get() = ConfigKey.Domain("")
override val sortOrders: Set<SortOrder> override val availableSortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java) get() = EnumSet.allOf(SortOrder::class.java)
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
override suspend fun getList( override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
@@ -39,7 +35,7 @@ class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSourc
TODO("Not yet implemented") TODO("Not yet implemented")
} }
override suspend fun getTags(): Set<MangaTag> { override suspend fun getAvailableTags(): Set<MangaTag> {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
} }

View File

@@ -83,6 +83,16 @@
<data android:host="kotatsu.app" /> <data android:host="kotatsu.app" />
<data android:path="/manga" /> <data android:path="/manga" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kotatsu" />
<data android:host="manga" />
<data android:host="kotatsu.app" />
</intent-filter>
</activity> </activity>
<activity <activity
android:name="org.koitharu.kotatsu.reader.ui.ReaderActivity" android:name="org.koitharu.kotatsu.reader.ui.ReaderActivity"
@@ -211,6 +221,9 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name="org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity"
android:label="@string/sources_catalog" />
<service <service
android:name="androidx.work.impl.foreground.SystemForegroundService" android:name="androidx.work.impl.foreground.SystemForegroundService"
@@ -327,6 +340,13 @@
<action android:name="android.intent.action.DOWNLOAD_COMPLETE" /> <action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<receiver
android:name="org.koitharu.kotatsu.core.ErrorReporterReceiver"
android:exported="false">
<intent-filter>
<action android:name="${applicationId}.action.REPORT_ERROR" />
</intent-filter>
</receiver>
<meta-data <meta-data
android:name="android.webkit.WebView.EnableSafeBrowsing" android:name="android.webkit.WebView.EnableSafeBrowsing"

View File

@@ -1,7 +1,7 @@
package org.koitharu.kotatsu.bookmarks.domain package org.koitharu.kotatsu.bookmarks.domain
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.local.data.ImageFileFilter import org.koitharu.kotatsu.local.data.hasImageExtension
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import java.util.Date import java.util.Date
@@ -38,7 +38,6 @@ data class Bookmark(
) )
private fun isImageUrlDirect(): Boolean { private fun isImageUrlDirect(): Boolean {
val extension = imageUrl.substringAfterLast('.') return hasImageExtension(imageUrl)
return extension.isNotEmpty() && ImageFileFilter().isExtensionValid(extension)
} }
} }

View File

@@ -25,15 +25,15 @@ class BookmarksRepository @Inject constructor(
) { ) {
fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow<Bookmark?> { fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow<Bookmark?> {
return db.bookmarksDao.observe(manga.id, chapterId, page).map { it?.toBookmark(manga) } return db.getBookmarksDao().observe(manga.id, chapterId, page).map { it?.toBookmark(manga) }
} }
fun observeBookmarks(manga: Manga): Flow<List<Bookmark>> { fun observeBookmarks(manga: Manga): Flow<List<Bookmark>> {
return db.bookmarksDao.observe(manga.id).mapItems { it.toBookmark(manga) } return db.getBookmarksDao().observe(manga.id).mapItems { it.toBookmark(manga) }
} }
fun observeBookmarks(): Flow<Map<Manga, List<Bookmark>>> { fun observeBookmarks(): Flow<Map<Manga, List<Bookmark>>> {
return db.bookmarksDao.observe().map { map -> return db.getBookmarksDao().observe().map { map ->
val res = LinkedHashMap<Manga, List<Bookmark>>(map.size) val res = LinkedHashMap<Manga, List<Bookmark>>(map.size)
for ((k, v) in map) { for ((k, v) in map) {
val manga = k.toManga() val manga = k.toManga()
@@ -46,9 +46,9 @@ class BookmarksRepository @Inject constructor(
suspend fun addBookmark(bookmark: Bookmark) { suspend fun addBookmark(bookmark: Bookmark) {
db.withTransaction { db.withTransaction {
val tags = bookmark.manga.tags.toEntities() val tags = bookmark.manga.tags.toEntities()
db.tagsDao.upsert(tags) db.getTagsDao().upsert(tags)
db.mangaDao.upsert(bookmark.manga.toEntity(), tags) db.getMangaDao().upsert(bookmark.manga.toEntity(), tags)
db.bookmarksDao.insert(bookmark.toEntity()) db.getBookmarksDao().insert(bookmark.toEntity())
} }
} }
@@ -56,11 +56,11 @@ class BookmarksRepository @Inject constructor(
val entity = bookmark.toEntity().copy( val entity = bookmark.toEntity().copy(
imageUrl = imageUrl, imageUrl = imageUrl,
) )
db.bookmarksDao.upsert(listOf(entity)) db.getBookmarksDao().upsert(listOf(entity))
} }
suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) { suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) {
check(db.bookmarksDao.delete(mangaId, chapterId, page) != 0) { check(db.getBookmarksDao().delete(mangaId, chapterId, page) != 0) {
"Bookmark not found" "Bookmark not found"
} }
} }
@@ -72,7 +72,7 @@ class BookmarksRepository @Inject constructor(
suspend fun removeBookmarks(ids: Set<Long>): ReversibleHandle { suspend fun removeBookmarks(ids: Set<Long>): ReversibleHandle {
val entities = ArrayList<BookmarkEntity>(ids.size) val entities = ArrayList<BookmarkEntity>(ids.size)
db.withTransaction { db.withTransaction {
val dao = db.bookmarksDao val dao = db.getBookmarksDao()
for (pageId in ids) { for (pageId in ids) {
val e = dao.find(pageId) val e = dao.find(pageId)
if (e != null) { if (e != null) {
@@ -92,7 +92,7 @@ class BookmarksRepository @Inject constructor(
db.withTransaction { db.withTransaction {
for (e in entities) { for (e in entities) {
try { try {
db.bookmarksDao.insert(e) db.getBookmarksDao().insert(e)
} catch (e: SQLException) { } catch (e: SQLException) {
e.printStackTraceDebug() e.printStackTraceDebug()
} }

View File

@@ -34,8 +34,8 @@ class BookmarksActivity :
val fm = supportFragmentManager val fm = supportFragmentManager
if (fm.findFragmentById(R.id.container) == null) { if (fm.findFragmentById(R.id.container) == null) {
fm.commit { fm.commit {
val fragment = BookmarksFragment.newInstance() setReorderingAllowed(true)
replace(R.id.container, fragment) replace(R.id.container, BookmarksFragment::class.java, null)
} }
} }
} }

View File

@@ -48,8 +48,8 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
if (!catchingWebViewUnavailability { if (!catchingWebViewUnavailability {
setContentView( setContentView(
ActivityBrowserBinding.inflate( ActivityBrowserBinding.inflate(
layoutInflater layoutInflater,
) ),
) )
}) { }) {
return return
@@ -82,9 +82,11 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
} }
override fun onDestroy() { override fun onDestroy() {
viewBinding.webView.run { runCatching {
stopLoading() viewBinding.webView
destroy() }.onSuccess {
it.stopLoading()
it.destroy()
} }
super.onDestroy() super.onDestroy()
} }

View File

@@ -0,0 +1,33 @@
package org.koitharu.kotatsu.core
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.app.PendingIntentCompat
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.report
class ErrorReporterReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val e = intent?.getSerializableExtraCompat<Throwable>(EXTRA_ERROR) ?: return
e.report()
}
companion object {
private const val EXTRA_ERROR = "err"
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
fun getPendingIntent(context: Context, e: Throwable): PendingIntent {
val intent = Intent(context, ErrorReporterReceiver::class.java)
intent.setAction(ACTION_REPORT)
intent.setData(Uri.parse("err://${e.hashCode()}"))
intent.putExtra(EXTRA_ERROR, e)
return checkNotNull(PendingIntentCompat.getBroadcast(context, 0, intent, 0, false))
}
}
}

View File

@@ -22,7 +22,7 @@ class BackupRepository @Inject constructor(
var offset = 0 var offset = 0
val entry = BackupEntry(BackupEntry.HISTORY, JSONArray()) val entry = BackupEntry(BackupEntry.HISTORY, JSONArray())
while (true) { while (true) {
val history = db.historyDao.findAll(offset, PAGE_SIZE) val history = db.getHistoryDao().findAll(offset, PAGE_SIZE)
if (history.isEmpty()) { if (history.isEmpty()) {
break break
} }
@@ -42,7 +42,7 @@ class BackupRepository @Inject constructor(
suspend fun dumpCategories(): BackupEntry { suspend fun dumpCategories(): BackupEntry {
val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray()) val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray())
val categories = db.favouriteCategoriesDao.findAll() val categories = db.getFavouriteCategoriesDao().findAll()
for (item in categories) { for (item in categories) {
entry.data.put(JsonSerializer(item).toJson()) entry.data.put(JsonSerializer(item).toJson())
} }
@@ -53,7 +53,7 @@ class BackupRepository @Inject constructor(
var offset = 0 var offset = 0
val entry = BackupEntry(BackupEntry.FAVOURITES, JSONArray()) val entry = BackupEntry(BackupEntry.FAVOURITES, JSONArray())
while (true) { while (true) {
val favourites = db.favouritesDao.findAll(offset, PAGE_SIZE) val favourites = db.getFavouritesDao().findAll(offset, PAGE_SIZE)
if (favourites.isEmpty()) { if (favourites.isEmpty()) {
break break
} }
@@ -73,7 +73,7 @@ class BackupRepository @Inject constructor(
suspend fun dumpBookmarks(): BackupEntry { suspend fun dumpBookmarks(): BackupEntry {
val entry = BackupEntry(BackupEntry.BOOKMARKS, JSONArray()) val entry = BackupEntry(BackupEntry.BOOKMARKS, JSONArray())
val all = db.bookmarksDao.findAll() val all = db.getBookmarksDao().findAll()
for ((m, b) in all) { for ((m, b) in all) {
val json = JSONObject() val json = JSONObject()
val manga = JsonSerializer(m.manga).toJson() val manga = JsonSerializer(m.manga).toJson()
@@ -122,9 +122,9 @@ class BackupRepository @Inject constructor(
val history = JsonDeserializer(item).toHistoryEntity() val history = JsonDeserializer(item).toHistoryEntity()
result += runCatchingCancellable { result += runCatchingCancellable {
db.withTransaction { db.withTransaction {
db.tagsDao.upsert(tags) db.getTagsDao().upsert(tags)
db.mangaDao.upsert(manga, tags) db.getMangaDao().upsert(manga, tags)
db.historyDao.upsert(history) db.getHistoryDao().upsert(history)
} }
} }
} }
@@ -136,7 +136,7 @@ class BackupRepository @Inject constructor(
for (item in entry.data.JSONIterator()) { for (item in entry.data.JSONIterator()) {
val category = JsonDeserializer(item).toFavouriteCategoryEntity() val category = JsonDeserializer(item).toFavouriteCategoryEntity()
result += runCatchingCancellable { result += runCatchingCancellable {
db.favouriteCategoriesDao.upsert(category) db.getFavouriteCategoriesDao().upsert(category)
} }
} }
return result return result
@@ -153,9 +153,9 @@ class BackupRepository @Inject constructor(
val favourite = JsonDeserializer(item).toFavouriteEntity() val favourite = JsonDeserializer(item).toFavouriteEntity()
result += runCatchingCancellable { result += runCatchingCancellable {
db.withTransaction { db.withTransaction {
db.tagsDao.upsert(tags) db.getTagsDao().upsert(tags)
db.mangaDao.upsert(manga, tags) db.getMangaDao().upsert(manga, tags)
db.favouritesDao.upsert(favourite) db.getFavouritesDao().upsert(favourite)
} }
} }
} }
@@ -175,9 +175,9 @@ class BackupRepository @Inject constructor(
} }
result += runCatchingCancellable { result += runCatchingCancellable {
db.withTransaction { db.withTransaction {
db.tagsDao.upsert(tags) db.getTagsDao().upsert(tags)
db.mangaDao.upsert(manga, tags) db.getMangaDao().upsert(manga, tags)
db.bookmarksDao.upsert(bookmarks) db.getBookmarksDao().upsert(bookmarks)
} }
} }
} }

View File

@@ -29,7 +29,7 @@ class BackupZipOutput(val file: File) : Closeable {
} }
} }
private const val DIR_BACKUPS = "backups" const val DIR_BACKUPS = "backups"
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) { suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
val dir = context.run { val dir = context.run {

View File

@@ -66,29 +66,29 @@ const val DATABASE_VERSION = 17
) )
abstract class MangaDatabase : RoomDatabase() { abstract class MangaDatabase : RoomDatabase() {
abstract val historyDao: HistoryDao abstract fun getHistoryDao(): HistoryDao
abstract val tagsDao: TagsDao abstract fun getTagsDao(): TagsDao
abstract val mangaDao: MangaDao abstract fun getMangaDao(): MangaDao
abstract val favouritesDao: FavouritesDao abstract fun getFavouritesDao(): FavouritesDao
abstract val preferencesDao: PreferencesDao abstract fun getPreferencesDao(): PreferencesDao
abstract val favouriteCategoriesDao: FavouriteCategoriesDao abstract fun getFavouriteCategoriesDao(): FavouriteCategoriesDao
abstract val tracksDao: TracksDao abstract fun getTracksDao(): TracksDao
abstract val trackLogsDao: TrackLogsDao abstract fun getTrackLogsDao(): TrackLogsDao
abstract val suggestionDao: SuggestionDao abstract fun getSuggestionDao(): SuggestionDao
abstract val bookmarksDao: BookmarksDao abstract fun getBookmarksDao(): BookmarksDao
abstract val scrobblingDao: ScrobblingDao abstract fun getScrobblingDao(): ScrobblingDao
abstract val sourcesDao: MangaSourcesDao abstract fun getSourcesDao(): MangaSourcesDao
} }
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf( fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(

View File

@@ -4,10 +4,15 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction import androidx.room.Transaction
import androidx.room.Upsert import androidx.room.Upsert
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
@Dao @Dao
abstract class MangaSourcesDao { abstract class MangaSourcesDao {
@@ -15,11 +20,11 @@ abstract class MangaSourcesDao {
@Query("SELECT * FROM sources ORDER BY sort_key") @Query("SELECT * FROM sources ORDER BY sort_key")
abstract suspend fun findAll(): List<MangaSourceEntity> abstract suspend fun findAll(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key") @Query("SELECT * FROM sources WHERE enabled = 0 ORDER BY sort_key")
abstract suspend fun findAllEnabled(): List<MangaSourceEntity> abstract suspend fun findAllDisabled(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key") @Query("SELECT * FROM sources WHERE enabled = 0")
abstract fun observeEnabled(): Flow<List<MangaSourceEntity>> abstract fun observeDisabled(): Flow<List<MangaSourceEntity>>
@Query("SELECT * FROM sources ORDER BY sort_key") @Query("SELECT * FROM sources ORDER BY sort_key")
abstract fun observeAll(): Flow<List<MangaSourceEntity>> abstract fun observeAll(): Flow<List<MangaSourceEntity>>
@@ -40,6 +45,22 @@ abstract class MangaSourcesDao {
@Upsert @Upsert
abstract suspend fun upsert(entry: MangaSourceEntity) abstract suspend fun upsert(entry: MangaSourceEntity)
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
val orderBy = getOrderBy(order)
@Language("RoomSql")
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
return observeImpl(query)
}
suspend fun findAllEnabled(order: SourcesSortOrder): List<MangaSourceEntity> {
val orderBy = getOrderBy(order)
@Language("RoomSql")
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
return findAllImpl(query)
}
@Transaction @Transaction
open suspend fun setEnabled(source: String, isEnabled: Boolean) { open suspend fun setEnabled(source: String, isEnabled: Boolean) {
if (updateIsEnabled(source, isEnabled) == 0) { if (updateIsEnabled(source, isEnabled) == 0) {
@@ -54,4 +75,16 @@ abstract class MangaSourcesDao {
@Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source") @Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source")
protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int
@RawQuery(observedEntities = [MangaSourceEntity::class])
protected abstract fun observeImpl(query: SupportSQLiteQuery): Flow<List<MangaSourceEntity>>
@RawQuery
protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List<MangaSourceEntity>
private fun getOrderBy(order: SourcesSortOrder) = when (order) {
SourcesSortOrder.ALPHABETIC -> "source ASC"
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
SourcesSortOrder.MANUAL -> "sort_key ASC"
}
} }

View File

@@ -51,6 +51,28 @@ abstract class TagsDao {
) )
abstract suspend fun findTags(query: String, limit: Int): List<TagEntity> 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 @Upsert
abstract suspend fun upsert(tags: Iterable<TagEntity>) 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>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
fun Collection<TagEntity>.toMangaTagsList() = map(TagEntity::toMangaTag)
fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga( fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
id = this.id, id = this.id,
title = this.title, title = this.title,

View File

@@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration10To11 : Migration(10, 11) { class Migration10To11 : Migration(10, 11) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL( db.execSQL(
""" """
CREATE TABLE IF NOT EXISTS `bookmarks` ( CREATE TABLE IF NOT EXISTS `bookmarks` (
`manga_id` INTEGER NOT NULL, `manga_id` INTEGER NOT NULL,
@@ -20,7 +20,7 @@ class Migration10To11 : Migration(10, 11) {
FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE ) FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )
""".trimIndent() """.trimIndent()
) )
database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `bookmarks` (`manga_id`)") db.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `bookmarks` (`manga_id`)")
database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `bookmarks` (`page_id`)") db.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `bookmarks` (`page_id`)")
} }
} }

View File

@@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration11To12 : Migration(11, 12) { class Migration11To12 : Migration(11, 12) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL( db.execSQL(
""" """
CREATE TABLE IF NOT EXISTS `scrobblings` ( CREATE TABLE IF NOT EXISTS `scrobblings` (
`scrobbler` INTEGER NOT NULL, `scrobbler` INTEGER NOT NULL,
@@ -21,7 +21,7 @@ class Migration11To12 : Migration(11, 12) {
) )
""".trimIndent() """.trimIndent()
) )
database.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1") db.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
database.execSQL("ALTER TABLE bookmarks ADD COLUMN `percent` REAL NOT NULL DEFAULT -1") db.execSQL("ALTER TABLE bookmarks ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
} }
} }

View File

@@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration12To13 : Migration(12, 13) { class Migration12To13 : Migration(12, 13) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `show_in_lib` INTEGER NOT NULL DEFAULT 1") db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `show_in_lib` INTEGER NOT NULL DEFAULT 1")
database.execSQL("ALTER TABLE favourites ADD COLUMN `sort_key` INTEGER NOT NULL DEFAULT 0") db.execSQL("ALTER TABLE favourites ADD COLUMN `sort_key` INTEGER NOT NULL DEFAULT 0")
} }
} }

View File

@@ -5,11 +5,11 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration13To14 : Migration(13, 14) { class Migration13To14 : Migration(13, 14) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0") db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE favourites ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0") db.execSQL("ALTER TABLE favourites ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE history ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0") db.execSQL("ALTER TABLE history ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_brightness` REAL NOT NULL DEFAULT 0") db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_brightness` REAL NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_contrast` REAL NOT NULL DEFAULT 0") db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_contrast` REAL NOT NULL DEFAULT 0")
} }
} }

View File

@@ -5,5 +5,5 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration14To15 : Migration(14, 15) { class Migration14To15 : Migration(14, 15) {
override fun migrate(database: SupportSQLiteDatabase) = Unit override fun migrate(db: SupportSQLiteDatabase) = Unit
} }

View File

@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration15To16 : Migration(15, 16) { class Migration15To16 : Migration(15, 16) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_invert` INTEGER NOT NULL DEFAULT 0") db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_invert` INTEGER NOT NULL DEFAULT 0")
} }
} }

View File

@@ -10,9 +10,9 @@ class Migration16To17(context: Context) : Migration(16, 17) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context) private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE `sources` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))") db.execSQL("CREATE TABLE `sources` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))")
database.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)") db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty() val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty() val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
val sources = MangaSource.entries val sources = MangaSource.entries
@@ -30,7 +30,7 @@ class Migration16To17(context: Context) : Migration(16, 17) {
continue continue
} }
} }
database.execSQL( db.execSQL(
"INSERT INTO `sources` (`source`, `enabled`, `sort_key`) VALUES (?, ?, ?)", "INSERT INTO `sources` (`source`, `enabled`, `sort_key`) VALUES (?, ?, ?)",
arrayOf(name, (!isHidden).toInt(), sortKey), arrayOf(name, (!isHidden).toInt(), sortKey),
) )

View File

@@ -7,48 +7,48 @@ class Migration1To2 : Migration(1, 2) {
/** /**
* Adding foreign keys * Adding foreign keys
*/ */
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
/* manga_tags */ /* manga_tags */
database.execSQL( db.execSQL(
"CREATE TABLE IF NOT EXISTS manga_tags_tmp (manga_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, " + "CREATE TABLE IF NOT EXISTS manga_tags_tmp (manga_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, " +
"PRIMARY KEY(manga_id, tag_id), " + "PRIMARY KEY(manga_id, tag_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE, " + "FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE, " +
"FOREIGN KEY(tag_id) REFERENCES tags(tag_id) ON UPDATE NO ACTION ON DELETE CASCADE )" "FOREIGN KEY(tag_id) REFERENCES tags(tag_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
) )
database.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_manga_id ON manga_tags_tmp (manga_id)") db.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_manga_id ON manga_tags_tmp (manga_id)")
database.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_tag_id ON manga_tags_tmp (tag_id)") db.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_tag_id ON manga_tags_tmp (tag_id)")
database.execSQL("INSERT INTO manga_tags_tmp (manga_id, tag_id) SELECT manga_id, tag_id FROM manga_tags") db.execSQL("INSERT INTO manga_tags_tmp (manga_id, tag_id) SELECT manga_id, tag_id FROM manga_tags")
database.execSQL("DROP TABLE manga_tags") db.execSQL("DROP TABLE manga_tags")
database.execSQL("ALTER TABLE manga_tags_tmp RENAME TO manga_tags") db.execSQL("ALTER TABLE manga_tags_tmp RENAME TO manga_tags")
/* favourites */ /* favourites */
database.execSQL( db.execSQL(
"CREATE TABLE IF NOT EXISTS favourites_tmp (manga_id INTEGER NOT NULL, category_id INTEGER NOT NULL, created_at INTEGER NOT NULL, " + "CREATE TABLE IF NOT EXISTS favourites_tmp (manga_id INTEGER NOT NULL, category_id INTEGER NOT NULL, created_at INTEGER NOT NULL, " +
"PRIMARY KEY(manga_id, category_id), " + "PRIMARY KEY(manga_id, category_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE , " + "FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE , " +
"FOREIGN KEY(category_id) REFERENCES favourite_categories(category_id) ON UPDATE NO ACTION ON DELETE CASCADE )" "FOREIGN KEY(category_id) REFERENCES favourite_categories(category_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
) )
database.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_manga_id ON favourites_tmp (manga_id)") db.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_manga_id ON favourites_tmp (manga_id)")
database.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_category_id ON favourites_tmp (category_id)") db.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_category_id ON favourites_tmp (category_id)")
database.execSQL("INSERT INTO favourites_tmp (manga_id, category_id, created_at) SELECT manga_id, category_id, created_at FROM favourites") db.execSQL("INSERT INTO favourites_tmp (manga_id, category_id, created_at) SELECT manga_id, category_id, created_at FROM favourites")
database.execSQL("DROP TABLE favourites") db.execSQL("DROP TABLE favourites")
database.execSQL("ALTER TABLE favourites_tmp RENAME TO favourites") db.execSQL("ALTER TABLE favourites_tmp RENAME TO favourites")
/* history */ /* history */
database.execSQL( db.execSQL(
"CREATE TABLE IF NOT EXISTS history_tmp (manga_id INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, chapter_id INTEGER NOT NULL, page INTEGER NOT NULL, " + "CREATE TABLE IF NOT EXISTS history_tmp (manga_id INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, chapter_id INTEGER NOT NULL, page INTEGER NOT NULL, " +
"PRIMARY KEY(manga_id), " + "PRIMARY KEY(manga_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )" "FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
) )
database.execSQL("INSERT INTO history_tmp (manga_id, created_at, updated_at, chapter_id, page) SELECT manga_id, created_at, updated_at, chapter_id, page FROM history") db.execSQL("INSERT INTO history_tmp (manga_id, created_at, updated_at, chapter_id, page) SELECT manga_id, created_at, updated_at, chapter_id, page FROM history")
database.execSQL("DROP TABLE history") db.execSQL("DROP TABLE history")
database.execSQL("ALTER TABLE history_tmp RENAME TO history") db.execSQL("ALTER TABLE history_tmp RENAME TO history")
/* preferences */ /* preferences */
database.execSQL( db.execSQL(
"CREATE TABLE IF NOT EXISTS preferences_tmp (manga_id INTEGER NOT NULL, mode INTEGER NOT NULL," + "CREATE TABLE IF NOT EXISTS preferences_tmp (manga_id INTEGER NOT NULL, mode INTEGER NOT NULL," +
" PRIMARY KEY(manga_id), " + " PRIMARY KEY(manga_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )" "FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
) )
database.execSQL("INSERT INTO preferences_tmp (manga_id, mode) SELECT manga_id, mode FROM preferences") db.execSQL("INSERT INTO preferences_tmp (manga_id, mode) SELECT manga_id, mode FROM preferences")
database.execSQL("DROP TABLE preferences") db.execSQL("DROP TABLE preferences")
database.execSQL("ALTER TABLE preferences_tmp RENAME TO preferences") db.execSQL("ALTER TABLE preferences_tmp RENAME TO preferences")
} }
} }

View File

@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration2To3 : Migration(2, 3) { class Migration2To3 : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE history ADD COLUMN scroll REAL NOT NULL DEFAULT 0") db.execSQL("ALTER TABLE history ADD COLUMN scroll REAL NOT NULL DEFAULT 0")
} }
} }

View File

@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration3To4 : Migration(3, 4) { class Migration3To4 : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS tracks (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )") db.execSQL("CREATE TABLE IF NOT EXISTS tracks (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )")
} }
} }

View File

@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration4To5 : Migration(4, 5) { class Migration4To5 : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN sort_key INTEGER NOT NULL DEFAULT 0") db.execSQL("ALTER TABLE favourite_categories ADD COLUMN sort_key INTEGER NOT NULL DEFAULT 0")
} }
} }

View File

@@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration5To6 : Migration(5, 6) { class Migration5To6 : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS track_logs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, manga_id INTEGER NOT NULL, chapters TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE)") db.execSQL("CREATE TABLE IF NOT EXISTS track_logs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, manga_id INTEGER NOT NULL, chapters TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE)")
database.execSQL("CREATE INDEX IF NOT EXISTS index_track_logs_manga_id ON track_logs (manga_id)") db.execSQL("CREATE INDEX IF NOT EXISTS index_track_logs_manga_id ON track_logs (manga_id)")
} }
} }

View File

@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration6To7 : Migration(6, 7) { class Migration6To7 : Migration(6, 7) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE manga ADD COLUMN public_url TEXT NOT NULL DEFAULT ''") db.execSQL("ALTER TABLE manga ADD COLUMN public_url TEXT NOT NULL DEFAULT ''")
} }
} }

View File

@@ -5,9 +5,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration7To8 : Migration(7, 8) { class Migration7To8 : Migration(7, 8) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE manga ADD COLUMN nsfw INTEGER NOT NULL DEFAULT 0") db.execSQL("ALTER TABLE manga ADD COLUMN nsfw INTEGER NOT NULL DEFAULT 0")
database.execSQL("CREATE TABLE IF NOT EXISTS suggestions (manga_id INTEGER NOT NULL, relevance REAL NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )") db.execSQL("CREATE TABLE IF NOT EXISTS suggestions (manga_id INTEGER NOT NULL, relevance REAL NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )")
database.execSQL("CREATE INDEX IF NOT EXISTS index_suggestions_manga_id ON suggestions (manga_id)") db.execSQL("CREATE INDEX IF NOT EXISTS index_suggestions_manga_id ON suggestions (manga_id)")
} }
} }

View File

@@ -6,7 +6,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
class Migration8To9 : Migration(8, 9) { class Migration8To9 : Migration(8, 9) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `order` TEXT NOT NULL DEFAULT ${SortOrder.NEWEST.name}") db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `order` TEXT NOT NULL DEFAULT ${SortOrder.NEWEST.name}")
} }
} }

View File

@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration9To10 : Migration(9, 10) { class Migration9To10 : Migration(9, 10) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `track` INTEGER NOT NULL DEFAULT 1") db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `track` INTEGER NOT NULL DEFAULT 1")
} }
} }

View File

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

View File

@@ -1,12 +1,15 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.core.model
import android.net.Uri import android.net.Uri
import androidx.annotation.StringRes
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.iterator import org.koitharu.kotatsu.core.util.ext.iterator
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
@JvmName("mangaIds") @JvmName("mangaIds")
@@ -31,6 +34,15 @@ fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
return acc.values.max() return acc.values.max()
} }
@get:StringRes
val MangaState.titleResId: Int
get() = when (this) {
MangaState.ONGOING -> R.string.state_ongoing
MangaState.FINISHED -> R.string.state_finished
MangaState.ABANDONED -> R.string.state_abandoned
MangaState.PAUSED -> R.string.state_paused
}
fun Manga.findChapter(id: Long): MangaChapter? { fun Manga.findChapter(id: Long): MangaChapter? {
return chapters?.findById(id) return chapters?.findById(id)
} }

View File

@@ -1,5 +1,8 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.core.model
import android.content.Context
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
@@ -18,3 +21,18 @@ fun MangaSource(name: String): MangaSource {
} }
fun MangaSource.isNsfw() = contentType == ContentType.HENTAI fun MangaSource.isNsfw() = contentType == ContentType.HENTAI
@get:StringRes
val ContentType.titleResId
get() = when (this) {
ContentType.MANGA -> R.string.content_type_manga
ContentType.HENTAI -> R.string.content_type_hentai
ContentType.COMICS -> R.string.content_type_comics
ContentType.OTHER -> R.string.content_type_other
}
fun MangaSource.getSummary(context: Context): String {
val type = context.getString(contentType.titleResId)
val locale = getLocaleTitle() ?: context.getString(R.string.various_languages)
return context.getString(R.string.source_summary_pattern, type, locale)
}

View File

@@ -14,8 +14,8 @@ class CloudFlareInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request()) val response = chain.proceed(chain.request())
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) { if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
val content = response.body?.source()?.peek()?.use { val content = response.body?.let { response.peekBody(Long.MAX_VALUE) }?.byteStream()?.use {
Jsoup.parse(it.inputStream(), Charsets.UTF_8.name(), response.request.url.toString()) Jsoup.parse(it, Charsets.UTF_8.name(), response.request.url.toString())
} ?: return response } ?: return response
if (content.getElementById("challenge-error-title") != null) { if (content.getElementById("challenge-error-title") != null) {
val request = response.request val request = response.request

View File

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

View File

@@ -63,6 +63,9 @@ class MirrorSwitchInterceptor @Inject constructor(
} }
synchronized(obtainLock(repository.source)) { synchronized(obtainLock(repository.source)) {
val currentMirror = repository.domain val currentMirror = repository.domain
if (currentMirror !in mirrors) {
return@synchronized false
}
addToBlacklist(repository.source, currentMirror) addToBlacklist(repository.source, currentMirror)
val newMirror = mirrors.firstOrNull { x -> val newMirror = mirrors.firstOrNull { x ->
x != currentMirror && !isBlacklisted(repository.source, x) x != currentMirror && !isBlacklisted(repository.source, x)

View File

@@ -11,6 +11,7 @@ import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -28,16 +29,16 @@ class MangaDataRepository @Inject constructor(
suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) { suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) {
db.withTransaction { db.withTransaction {
storeManga(manga) storeManga(manga)
val entity = db.preferencesDao.find(manga.id) ?: newEntity(manga.id) val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id)
db.preferencesDao.upsert(entity.copy(mode = mode.id)) db.getPreferencesDao().upsert(entity.copy(mode = mode.id))
} }
} }
suspend fun saveColorFilter(manga: Manga, colorFilter: ReaderColorFilter?) { suspend fun saveColorFilter(manga: Manga, colorFilter: ReaderColorFilter?) {
db.withTransaction { db.withTransaction {
storeManga(manga) storeManga(manga)
val entity = db.preferencesDao.find(manga.id) ?: newEntity(manga.id) val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id)
db.preferencesDao.upsert( db.getPreferencesDao().upsert(
entity.copy( entity.copy(
cfBrightness = colorFilter?.brightness ?: 0f, cfBrightness = colorFilter?.brightness ?: 0f,
cfContrast = colorFilter?.contrast ?: 0f, cfContrast = colorFilter?.contrast ?: 0f,
@@ -48,25 +49,25 @@ class MangaDataRepository @Inject constructor(
} }
suspend fun getReaderMode(mangaId: Long): ReaderMode? { suspend fun getReaderMode(mangaId: Long): ReaderMode? {
return db.preferencesDao.find(mangaId)?.let { ReaderMode.valueOf(it.mode) } return db.getPreferencesDao().find(mangaId)?.let { ReaderMode.valueOf(it.mode) }
} }
suspend fun getColorFilter(mangaId: Long): ReaderColorFilter? { suspend fun getColorFilter(mangaId: Long): ReaderColorFilter? {
return db.preferencesDao.find(mangaId)?.getColorFilterOrNull() return db.getPreferencesDao().find(mangaId)?.getColorFilterOrNull()
} }
fun observeColorFilter(mangaId: Long): Flow<ReaderColorFilter?> { fun observeColorFilter(mangaId: Long): Flow<ReaderColorFilter?> {
return db.preferencesDao.observe(mangaId) return db.getPreferencesDao().observe(mangaId)
.map { it?.getColorFilterOrNull() } .map { it?.getColorFilterOrNull() }
.distinctUntilChanged() .distinctUntilChanged()
} }
suspend fun findMangaById(mangaId: Long): Manga? { suspend fun findMangaById(mangaId: Long): Manga? {
return db.mangaDao.find(mangaId)?.toManga() return db.getMangaDao().find(mangaId)?.toManga()
} }
suspend fun findMangaByPublicUrl(publicUrl: String): Manga? { suspend fun findMangaByPublicUrl(publicUrl: String): Manga? {
return db.mangaDao.findByPublicUrl(publicUrl)?.toManga() return db.getMangaDao().findByPublicUrl(publicUrl)?.toManga()
} }
suspend fun resolveIntent(intent: MangaIntent): Manga? = when { suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
@@ -77,15 +78,23 @@ class MangaDataRepository @Inject constructor(
} }
suspend fun storeManga(manga: Manga) { suspend fun storeManga(manga: Manga) {
val tags = manga.tags.toEntities()
db.withTransaction { db.withTransaction {
db.tagsDao.upsert(tags) // avoid storing local manga if remote one is already stored
db.mangaDao.upsert(manga.toEntity(), tags) val existing = if (manga.isLocal) {
db.getMangaDao().find(manga.id)?.manga
} else {
null
}
if (existing == null || existing.source == manga.source.name) {
val tags = manga.tags.toEntities()
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga.toEntity(), tags)
}
} }
} }
suspend fun findTags(source: MangaSource): Set<MangaTag> { suspend fun findTags(source: MangaSource): Set<MangaTag> {
return db.tagsDao.findTags(source.name).toMangaTags() return db.getTagsDao().findTags(source.name).toMangaTags()
} }
private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? { private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? {

View File

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

View File

@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.almostEquals import org.koitharu.kotatsu.parsers.util.almostEquals
import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.levenshteinDistance
@@ -23,14 +24,14 @@ class MangaLinkResolver @Inject constructor(
) { ) {
suspend fun resolve(uri: Uri): Manga { suspend fun resolve(uri: Uri): Manga {
return if (uri.host == "kotatsu.app") { return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") {
resolveAppLink(uri) resolveAppLink(uri)
} else { } else {
resolveExternalLink(uri) resolveExternalLink(uri)
} ?: throw NotFoundException("Manga not found", uri.toString()) } ?: throw NotFoundException("Cannot resolve link", uri.toString())
} }
suspend fun resolveAppLink(uri: Uri): Manga? { private suspend fun resolveAppLink(uri: Uri): Manga? {
require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" } require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" }
val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" } val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" }
val source = MangaSource(sourceName) val source = MangaSource(sourceName)
@@ -42,7 +43,7 @@ class MangaLinkResolver @Inject constructor(
) )
} }
suspend fun resolveExternalLink(uri: Uri): Manga? { private suspend fun resolveExternalLink(uri: Uri): Manga? {
dataRepository.findMangaByPublicUrl(uri.toString())?.let { dataRepository.findMangaByPublicUrl(uri.toString())?.let {
return it return it
} }
@@ -58,7 +59,7 @@ class MangaLinkResolver @Inject constructor(
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? { private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
if (!title.isNullOrEmpty()) { if (!title.isNullOrEmpty()) {
val list = getList(0, title) val list = getList(0, MangaListFilter.Search(title))
if (url != null) { if (url != null) {
list.find { it.url == url }?.let { list.find { it.url == url }?.let {
return it return it
@@ -77,7 +78,7 @@ class MangaLinkResolver @Inject constructor(
}.ifNullOrEmpty { }.ifNullOrEmpty {
seed.author seed.author
} ?: return@runCatchingCancellable null } ?: return@runCatchingCancellable null
val seedList = getList(0, seedTitle) val seedList = getList(0, MangaListFilter.Search(seedTitle))
seedList.first { x -> x.url == url } seedList.first { x -> x.url == url }
}.getOrThrow() }.getOrThrow()
} }

View File

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

View File

@@ -7,8 +7,10 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
@@ -23,11 +25,13 @@ interface MangaRepository {
val sortOrders: Set<SortOrder> val sortOrders: Set<SortOrder>
val states: Set<MangaState>
var defaultSortOrder: SortOrder var defaultSortOrder: SortOrder
suspend fun getList(offset: Int, query: String): List<Manga> val isMultipleTagsSupported: Boolean
suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga>
suspend fun getDetails(manga: Manga): Manga suspend fun getDetails(manga: Manga): Manga

View File

@@ -23,8 +23,10 @@ import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.Favicons import org.koitharu.kotatsu.parsers.model.Favicons
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.domain
@@ -40,7 +42,10 @@ class RemoteMangaRepository(
get() = parser.source get() = parser.source
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = parser.sortOrders get() = parser.availableSortOrders
override val states: Set<MangaState>
get() = parser.availableStates
override var defaultSortOrder: SortOrder override var defaultSortOrder: SortOrder
get() = getConfig().defaultSortOrder ?: sortOrders.first() get() = getConfig().defaultSortOrder ?: sortOrders.first()
@@ -48,6 +53,9 @@ class RemoteMangaRepository(
getConfig().defaultSortOrder = value getConfig().defaultSortOrder = value
} }
override val isMultipleTagsSupported: Boolean
get() = parser.isMultipleTagsSupported
var domain: String var domain: String
get() = parser.domain get() = parser.domain
set(value) { set(value) {
@@ -68,15 +76,9 @@ class RemoteMangaRepository(
} }
} }
override suspend fun getList(offset: Int, query: String): List<Manga> { override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
return mirrorSwitchInterceptor.withMirrorSwitching { return mirrorSwitchInterceptor.withMirrorSwitching {
parser.getList(offset, query) parser.getList(offset, filter)
}
}
override suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> {
return mirrorSwitchInterceptor.withMirrorSwitching {
parser.getList(offset, tags, sortOrder)
} }
} }
@@ -98,7 +100,7 @@ class RemoteMangaRepository(
} }
override suspend fun getTags(): Set<MangaTag> = mirrorSwitchInterceptor.withMirrorSwitching { override suspend fun getTags(): Set<MangaTag> = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getTags() parser.getAvailableTags()
} }
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching { suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
@@ -128,8 +130,12 @@ class RemoteMangaRepository(
return details.await() return details.await()
} }
suspend fun peekDetails(manga: Manga): Manga? {
return cache.getDetails(source, manga.url)
}
suspend fun find(manga: Manga): Manga? { suspend fun find(manga: Manga): Manga? {
val list = getList(0, manga.title) val list = getList(0, MangaListFilter.Search(manga.title))
return list.find { x -> x.id == manga.id } return list.find { x -> x.id == manga.id }
} }

View File

@@ -22,7 +22,8 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.core.util.ext.takeIfReadable import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.history.domain.model.HistoryOrder import org.koitharu.kotatsu.explore.data.SourcesSortOrder
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
@@ -72,6 +73,18 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getInt(KEY_GRID_SIZE, 100) get() = prefs.getInt(KEY_GRID_SIZE, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) } 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 var isNsfwContentDisabled: Boolean
get() = prefs.getBoolean(KEY_DISABLE_NSFW, false) get() = prefs.getBoolean(KEY_DISABLE_NSFW, false)
set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) } set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) }
@@ -96,6 +109,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderTapsAdaptive: Boolean val isReaderTapsAdaptive: Boolean
get() = !prefs.getBoolean(KEY_READER_TAPS_LTR, false) get() = !prefs.getBoolean(KEY_READER_TAPS_LTR, false)
val isReaderOptimizationEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false)
var isTrafficWarningEnabled: Boolean var isTrafficWarningEnabled: Boolean
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true) get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) } set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
@@ -176,7 +192,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) } set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
val isMirrorSwitchingAvailable: Boolean val isMirrorSwitchingAvailable: Boolean
get() = prefs.getBoolean(KEY_MIRROR_SWITCHING, true) get() = prefs.getBoolean(KEY_MIRROR_SWITCHING, false)
val isExitConfirmationEnabled: Boolean val isExitConfirmationEnabled: Boolean
get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false) get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false)
@@ -197,10 +213,17 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return policy.isNetworkAllowed(connectivityManager) return policy.isNetworkAllowed(connectivityManager)
} }
var sourcesSortOrder: SourcesSortOrder
get() = prefs.getEnumValue(KEY_SOURCES_ORDER, SourcesSortOrder.MANUAL)
set(value) = prefs.edit { putEnumValue(KEY_SOURCES_ORDER, value) }
var isSourcesGridMode: Boolean var isSourcesGridMode: Boolean
get() = prefs.getBoolean(KEY_SOURCES_GRID, false) get() = prefs.getBoolean(KEY_SOURCES_GRID, false)
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) } set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
val isNewSourcesTipEnabled: Boolean
get() = prefs.getBoolean(KEY_SOURCES_NEW, true)
val isPagesNumbersEnabled: Boolean val isPagesNumbersEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false) get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
@@ -304,8 +327,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST) get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST)
set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) } set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) }
var historySortOrder: HistoryOrder var historySortOrder: ListSortOrder
get() = prefs.getEnumValue(KEY_HISTORY_ORDER, HistoryOrder.UPDATED) get() = prefs.getEnumValue(KEY_HISTORY_ORDER, ListSortOrder.UPDATED)
set(value) = prefs.edit { putEnumValue(KEY_HISTORY_ORDER, value) } set(value) = prefs.edit { putEnumValue(KEY_HISTORY_ORDER, value) }
val isRelatedMangaEnabled: Boolean val isRelatedMangaEnabled: Boolean
@@ -336,6 +359,19 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return policy.isNetworkAllowed(connectivityManager) return policy.isNetworkAllowed(connectivityManager)
} }
val is32BitColorsEnabled: Boolean
get() = prefs.getBoolean(KEY_32BIT_COLOR, false)
val isPeriodicalBackupEnabled: Boolean
get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false)
val periodicalBackupFrequency: Long
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L
var periodicalBackupOutput: Uri?
get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull()
set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) }
fun isTipEnabled(tip: String): Boolean { fun isTipEnabled(tip: String): Boolean {
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
} }
@@ -401,6 +437,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val TRACK_FAVOURITES = "favourites" const val TRACK_FAVOURITES = "favourites"
const val KEY_LIST_MODE = "list_mode_2" 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_THEME = "theme"
const val KEY_COLOR_THEME = "color_theme" const val KEY_COLOR_THEME = "color_theme"
const val KEY_THEME_AMOLED = "amoled_theme" const val KEY_THEME_AMOLED = "amoled_theme"
@@ -437,6 +476,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_ZOOM_MODE = "zoom_mode" const val KEY_ZOOM_MODE = "zoom_mode"
const val KEY_BACKUP = "backup" const val KEY_BACKUP = "backup"
const val KEY_RESTORE = "restore" const val KEY_RESTORE = "restore"
const val KEY_BACKUP_PERIODICAL_ENABLED = "backup_periodic"
const val KEY_BACKUP_PERIODICAL_FREQUENCY = "backup_periodic_freq"
const val KEY_BACKUP_PERIODICAL_OUTPUT = "backup_periodic_output"
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
const val KEY_HISTORY_GROUPING = "history_grouping" const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_READING_INDICATORS = "reading_indicators" const val KEY_READING_INDICATORS = "reading_indicators"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters" const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
@@ -466,6 +509,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_SCREEN_ON = "reader_screen_on" const val KEY_READER_SCREEN_ON = "reader_screen_on"
const val KEY_SHORTCUTS = "dynamic_shortcuts" const val KEY_SHORTCUTS = "dynamic_shortcuts"
const val KEY_READER_TAPS_LTR = "reader_taps_ltr" const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
const val KEY_READER_OPTIMIZE = "reader_optimize"
const val KEY_LOCAL_LIST_ORDER = "local_order" const val KEY_LOCAL_LIST_ORDER = "local_order"
const val KEY_HISTORY_ORDER = "history_order" const val KEY_HISTORY_ORDER = "history_order"
const val KEY_WEBTOON_ZOOM = "webtoon_zoom" const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
@@ -474,6 +518,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LOGGING_ENABLED = "logging" const val KEY_LOGGING_ENABLED = "logging"
const val KEY_LOGS_SHARE = "logs_share" const val KEY_LOGS_SHARE = "logs_share"
const val KEY_SOURCES_GRID = "sources_grid" const val KEY_SOURCES_GRID = "sources_grid"
const val KEY_SOURCES_NEW = "sources_new"
const val KEY_UPDATES_UNSTABLE = "updates_unstable" const val KEY_UPDATES_UNSTABLE = "updates_unstable"
const val KEY_TIPS_CLOSED = "tips_closed" const val KEY_TIPS_CLOSED = "tips_closed"
const val KEY_SSL_BYPASS = "ssl_bypass" const val KEY_SSL_BYPASS = "ssl_bypass"
@@ -491,6 +536,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_DISABLE_NSFW = "no_nsfw" const val KEY_DISABLE_NSFW = "no_nsfw"
const val KEY_RELATED_MANGA = "related_manga" const val KEY_RELATED_MANGA = "related_manga"
const val KEY_NAV_MAIN = "nav_main" const val KEY_NAV_MAIN = "nav_main"
const val KEY_32BIT_COLOR = "enhanced_colors"
const val KEY_SOURCES_ORDER = "sources_sort_order"
const val KEY_SOURCES_CATALOG = "sources_catalog"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

View File

@@ -6,7 +6,6 @@ import android.graphics.Color
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
@@ -96,10 +95,10 @@ abstract class BaseActivity<B : ViewBinding> :
insetsDelegate.onViewCreated(binding.root) insetsDelegate.onViewCreated(binding.root)
} }
override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) { override fun onSupportNavigateUp(): Boolean {
onBackPressed() dispatchNavigateUp()
true return true
} else super.onOptionsItemSelected(item) }
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
@@ -126,10 +125,10 @@ abstract class BaseActivity<B : ViewBinding> :
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ColorUtils.compositeColors( ColorUtils.compositeColors(
ContextCompat.getColor(this, com.google.android.material.R.color.m3_appbar_overlay_color), 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 { } else {
ContextCompat.getColor(this, R.color.kotatsu_secondaryContainer) ContextCompat.getColor(this, R.color.kotatsu_m3_background)
} }
val insets = ViewCompat.getRootWindowInsets(viewBinding.root) val insets = ViewCompat.getRootWindowInsets(viewBinding.root)
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return ?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
@@ -150,6 +149,17 @@ abstract class BaseActivity<B : ViewBinding> :
window.statusBarColor = defaultStatusBarColor window.statusBarColor = defaultStatusBarColor
} }
protected open fun dispatchNavigateUp() {
val upIntent = parentActivityIntent
if (upIntent != null) {
if (!navigateUpTo(upIntent)) {
startActivity(upIntent)
}
} else {
finishAfterTransition()
}
}
private fun putDataToExtras(intent: Intent?) { private fun putDataToExtras(intent: Intent?) {
intent?.putExtra(EXTRA_DATA, intent.data) intent?.putExtra(EXTRA_DATA, intent.data)
} }

View File

@@ -4,11 +4,11 @@ import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat
import androidx.core.text.htmlEncode import androidx.core.text.htmlEncode
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.core.text.parseAsHtml import androidx.core.text.parseAsHtml
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -37,7 +37,7 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
override fun onViewBindingCreated(binding: DialogErrorDetailsBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: DialogErrorDetailsBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
with(binding.textViewMessage) { with(binding.textViewMessage) {
movementMethod = LinkMovementMethod.getInstance() movementMethod = LinkMovementMethodCompat.getInstance()
text = context.getString( text = context.getString(
R.string.manga_error_description_pattern, R.string.manga_error_description_pattern,
exception.message?.htmlEncode().orEmpty(), exception.message?.htmlEncode().orEmpty(),

View File

@@ -0,0 +1,40 @@
package org.koitharu.kotatsu.core.ui.list
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
class RecyclerScrollKeeper(
private val rv: RecyclerView,
) : AdapterDataObserver() {
private val scrollUpRunnable = Runnable {
(rv.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(0, 0)
}
fun attach() {
rv.adapter?.registerAdapterDataObserver(this)
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
super.onItemRangeInserted(positionStart, itemCount)
if (positionStart == 0 && isScrolledToTop()) {
postScrollUp()
}
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
super.onItemRangeMoved(fromPosition, toPosition, itemCount)
if (toPosition == 0 && isScrolledToTop()) {
postScrollUp()
}
}
private fun postScrollUp() {
rv.postDelayed(scrollUpRunnable, 500L)
}
private fun isScrolledToTop(): Boolean {
return (rv.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition() == 0
}
}

View File

@@ -0,0 +1,88 @@
package org.koitharu.kotatsu.core.ui.list.lifecycle
import android.view.View
import androidx.annotation.CallSuper
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.recyclerview.widget.RecyclerView
abstract class LifecycleAwareViewHolder(
itemView: View,
private val parentLifecycleOwner: LifecycleOwner,
) : RecyclerView.ViewHolder(itemView), LifecycleOwner {
@Suppress("LeakingThis")
final override val lifecycle = LifecycleRegistry(this)
private var isCurrent = false
init {
parentLifecycleOwner.lifecycle.addObserver(ParentLifecycleObserver())
if (parentLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
}
}
fun setIsCurrent(value: Boolean) {
isCurrent = value
dispatchResumed()
}
@CallSuper
open fun onStart() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START)
@CallSuper
open fun onResume() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
@CallSuper
open fun onPause() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
@CallSuper
open fun onStop() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
private fun dispatchResumed() {
val isParentResumed = parentLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
if (isCurrent && isParentResumed) {
if (!isResumed()) {
onResume()
}
} else {
if (isResumed()) {
onPause()
}
}
}
protected fun isResumed(): Boolean {
return lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
}
private inner class ParentLifecycleObserver : DefaultLifecycleObserver {
override fun onCreate(owner: LifecycleOwner) {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
}
override fun onStart(owner: LifecycleOwner) {
onStart()
}
override fun onResume(owner: LifecycleOwner) {
dispatchResumed()
}
override fun onPause(owner: LifecycleOwner) {
dispatchResumed()
}
override fun onStop(owner: LifecycleOwner) {
onStop()
}
override fun onDestroy(owner: LifecycleOwner) {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
owner.lifecycle.removeObserver(this)
}
}
}

View File

@@ -0,0 +1,23 @@
package org.koitharu.kotatsu.core.ui.list.lifecycle
import androidx.core.view.children
import androidx.viewpager2.widget.ViewPager2
import org.koitharu.kotatsu.core.util.ext.recyclerView
class PagerLifecycleDispatcher(
private val pager: ViewPager2,
) : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
val rv = pager.recyclerView ?: return
for (child in rv.children) {
val wh = rv.getChildViewHolder(child) ?: continue
(wh as? LifecycleAwareViewHolder)?.setIsCurrent(wh.absoluteAdapterPosition == position)
}
}
fun invalidate() {
onPageSelected(pager.currentItem)
}
}

View File

@@ -0,0 +1,35 @@
package org.koitharu.kotatsu.core.ui.list.lifecycle
import androidx.core.view.children
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
class RecyclerViewLifecycleDispatcher : RecyclerView.OnScrollListener() {
private var prevFirst = NO_POSITION
private var prevLast = NO_POSITION
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
invalidate(recyclerView)
}
fun invalidate(recyclerView: RecyclerView) {
val lm = recyclerView.layoutManager as? LinearLayoutManager ?: return
val first = lm.findFirstVisibleItemPosition()
val last = lm.findLastVisibleItemPosition()
if (first == prevFirst && last == prevLast) {
return
}
prevFirst = first
prevLast = last
if (first == NO_POSITION || last == NO_POSITION) {
return
}
for (child in recyclerView.children) {
val wh = recyclerView.getChildViewHolder(child) ?: continue
(wh as? LifecycleAwareViewHolder)?.setIsCurrent(wh.absoluteAdapterPosition in first..last)
}
}
}

View File

@@ -0,0 +1,74 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.content.Context
import android.util.ArrayMap
import android.util.AttributeSet
import com.google.android.material.slider.Slider
import kotlin.math.cbrt
import kotlin.math.pow
class CubicSlider @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : Slider(context, attrs) {
private val changeListeners = ArrayMap<OnChangeListener, OnChangeListenerMapper>(1)
override fun setValue(value: Float) {
super.setValue(value.unmap())
}
override fun getValue(): Float {
return super.getValue().map()
}
override fun getValueFrom(): Float {
return super.getValueFrom().map()
}
override fun setValueFrom(valueFrom: Float) {
super.setValueFrom(valueFrom.unmap())
}
override fun getValueTo(): Float {
return super.getValueTo().map()
}
override fun setValueTo(valueTo: Float) {
super.setValueTo(valueTo.unmap())
}
override fun addOnChangeListener(listener: OnChangeListener) {
val mapper = OnChangeListenerMapper(listener)
super.addOnChangeListener(mapper)
changeListeners[listener] = mapper
}
override fun removeOnChangeListener(listener: OnChangeListener) {
changeListeners.remove(listener)?.let {
super.removeOnChangeListener(it)
}
}
override fun clearOnChangeListeners() {
super.clearOnChangeListeners()
changeListeners.clear()
}
private fun Float.map(): Float {
return this.pow(3)
}
private fun Float.unmap(): Float {
return cbrt(this)
}
private inner class OnChangeListenerMapper(
private val delegate: OnChangeListener,
) : OnChangeListener {
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
delegate.onValueChange(slider, value.map(), fromUser)
}
}
}

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ class CompositeMutex2<T : Any> : Set<T> {
} }
override fun isEmpty(): Boolean { override fun isEmpty(): Boolean {
return delegates.isEmpty return delegates.isEmpty()
} }
override fun iterator(): Iterator<T> { override fun iterator(): Iterator<T> {

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.core.util
import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.core.util.ext.map
import java.util.Locale
class LocaleComparator : Comparator<Locale?> {
private val deviceLocales = LocaleListCompat.getAdjustedDefault()//LocaleManagerCompat.getSystemLocales(context)
.map { it.language }
.distinct()
override fun compare(a: Locale?, b: Locale?): Int {
return if (a === b) {
0
} else {
val indexA = if (a == null) -1 else deviceLocales.indexOf(a.language)
val indexB = if (b == null) -1 else deviceLocales.indexOf(b.language)
if (indexA < 0 && indexB < 0) {
compareValues(a?.language, b?.language)
} else {
-2 - (indexA - indexB)
}
}
}
}

View File

@@ -6,13 +6,16 @@ import android.content.res.Configuration
import android.database.ContentObserver import android.database.ContentObserver
import android.os.Handler import android.os.Handler
import android.provider.Settings import android.provider.Settings
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import javax.inject.Inject
class ScreenOrientationHelper(private val activity: Activity) { @ActivityScoped
class ScreenOrientationHelper @Inject constructor(private val activity: Activity) {
val isAutoRotationEnabled: Boolean val isAutoRotationEnabled: Boolean
get() = Settings.System.getInt( get() = Settings.System.getInt(
@@ -31,9 +34,15 @@ class ScreenOrientationHelper(private val activity: Activity) {
} }
} }
fun toggleOrientation() { var isLocked: Boolean
isLandscape = !isLandscape get() = activity.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LOCKED
} set(value) {
activity.requestedOrientation = if (value) {
ActivityInfo.SCREEN_ORIENTATION_LOCKED
} else {
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
fun observeAutoOrientation() = callbackFlow { fun observeAutoOrientation() = callbackFlow {
val observer = object : ContentObserver(Handler(activity.mainLooper)) { val observer = object : ContentObserver(Handler(activity.mainLooper)) {

View File

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

View File

@@ -83,7 +83,7 @@ fun <I> ActivityResultLauncher<I>.tryLaunch(
e.printStackTraceDebug() e.printStackTraceDebug()
}.isSuccess }.isSuccess
fun SharedPreferences.observe() = callbackFlow<String?> { fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
trySendBlocking(key) trySendBlocking(key)
} }

View File

@@ -23,7 +23,7 @@ fun ImageView.newImageRequest(lifecycleOwner: LifecycleOwner, data: Any?): Image
return null return null
} }
} }
disposeImageRequest() // disposeImageRequest()
return ImageRequest.Builder(context) return ImageRequest.Builder(context)
.data(data) .data(data)
.lifecycle(lifecycleOwner) .lifecycle(lifecycleOwner)

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.util.ext
import androidx.collection.ArrayMap import androidx.collection.ArrayMap
import androidx.collection.ArraySet import androidx.collection.ArraySet
import org.koitharu.kotatsu.BuildConfig
import java.util.Collections import java.util.Collections
import java.util.EnumSet import java.util.EnumSet
@@ -55,3 +56,15 @@ inline fun <reified E : Enum<E>> Collection<E>.toEnumSet(): EnumSet<E> = if (isE
} else { } else {
EnumSet.copyOf(this) EnumSet.copyOf(this)
} }
fun <E : Enum<E>> Collection<E>.sortedByOrdinal() = sortedBy { it.ordinal }
fun <T> Iterable<T>.sortedWithSafe(comparator: Comparator<in T>): List<T> = try {
sortedWith(comparator)
} catch (e: IllegalArgumentException) {
if (BuildConfig.DEBUG) {
throw e
} else {
toList()
}
}

View File

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

View File

@@ -7,7 +7,6 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.annotation.WorkerThread
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
@@ -19,7 +18,9 @@ import java.io.FileFilter
import java.nio.file.attribute.BasicFileAttributes import java.nio.file.attribute.BasicFileAttributes
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.readAttributes import kotlin.io.path.readAttributes
import kotlin.io.path.walk
fun File.subdir(name: String) = File(this, name).also { fun File.subdir(name: String) = File(this, name).also {
if (!it.exists()) it.mkdirs() if (!it.exists()) it.mkdirs()
@@ -49,7 +50,7 @@ fun File.getStorageName(context: Context): String = runCatching {
} }
}.getOrNull() ?: context.getString(R.string.other_storage) }.getOrNull() ?: context.getString(R.string.other_storage)
fun Uri.toFileOrNull() = if (scheme == "file") path?.let(::File) else null fun Uri.toFileOrNull() = if (scheme == URI_SCHEME_FILE) path?.let(::File) else null
suspend fun File.deleteAwait() = withContext(Dispatchers.IO) { suspend fun File.deleteAwait() = withContext(Dispatchers.IO) {
delete() || deleteRecursively() delete() || deleteRecursively()
@@ -71,31 +72,7 @@ fun ContentResolver.resolveName(uri: Uri): String? {
} }
suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) { suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) {
computeSizeInternal(this) walkCompat().sumOf { it.length() }
}
@WorkerThread
private fun computeSizeInternal(file: File): Long {
return if (file.isDirectory) {
file.children().sumOf { computeSizeInternal(it) }
} else {
file.length()
}
}
fun File.listFilesRecursive(filter: FileFilter? = null): Sequence<File> = sequence {
listFilesRecursiveImpl(this@listFilesRecursive, filter)
}
private suspend fun SequenceScope<File>.listFilesRecursiveImpl(root: File, filter: FileFilter?) {
val ss = root.children()
for (f in ss) {
if (f.isDirectory) {
listFilesRecursiveImpl(f, filter)
} else if (filter == null || filter.accept(f)) {
yield(f)
}
}
} }
fun File.children() = FileSequence(this) fun File.children() = FileSequence(this)
@@ -108,3 +85,12 @@ val File.creationTime
} else { } else {
lastModified() lastModified()
} }
@OptIn(ExperimentalPathApi::class)
fun File.walkCompat() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Use lazy loading on Android 8.0 and later
toPath().walk().map { it.toFile() }
} else {
// Directories are excluded by default in Path.walk(), so do it here as well
walk().filter { it.isFile }
}

View File

@@ -26,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>> { inline fun <T, R> Flow<List<T>>.mapItems(crossinline transform: (T) -> R): Flow<List<R>> {
return map { list -> list.map(transform) } return map { list -> list.map(transform) }
} }

View File

@@ -8,6 +8,7 @@ import okhttp3.Response
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import okio.IOException import okio.IOException
import org.json.JSONObject import org.json.JSONObject
import org.jsoup.HttpStatusException
import java.net.HttpURLConnection import java.net.HttpURLConnection
private val TYPE_JSON = "application/json".toMediaType() private val TYPE_JSON = "application/json".toMediaType()
@@ -34,9 +35,8 @@ val HttpUrl.isHttpOrHttps: Boolean
fun Response.ensureSuccess() = apply { fun Response.ensureSuccess() = apply {
if (!isSuccessful || code == HttpURLConnection.HTTP_NO_CONTENT) { if (!isSuccessful || code == HttpURLConnection.HTTP_NO_CONTENT) {
val message = "Invalid response: $code $message at ${request.url}"
closeQuietly() closeQuietly()
throw IllegalStateException(message) throw HttpStatusException(message, code, request.url.toString())
} }
} }

View File

@@ -1,6 +1,9 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import android.content.Context
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale import java.util.Locale
operator fun LocaleListCompat.iterator(): ListIterator<Locale> = LocaleListCompatIterator(this) operator fun LocaleListCompat.iterator(): ListIterator<Locale> = LocaleListCompatIterator(this)
@@ -17,6 +20,14 @@ inline fun <T> LocaleListCompat.mapToSet(block: (Locale) -> T): Set<T> {
fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException() fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException()
fun String?.getLocaleDisplayName(context: Context): String {
if (this == null) {
return context.getString(R.string.various_languages)
}
val lc = Locale(this)
return lc.getDisplayLanguage(lc).toTitleCase(lc)
}
private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator<Locale> { private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator<Locale> {
private var index = 0 private var index = 0

View File

@@ -27,7 +27,10 @@ import java.net.SocketTimeoutException
import java.net.UnknownHostException import java.net.UnknownHostException
private const val MSG_NO_SPACE_LEFT = "No space left on device" private const val MSG_NO_SPACE_LEFT = "No space left on device"
private const val IMAGE_FORMAT_NO_SUPPORTED = "Image format not supported" private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
private const val MULTIPLE_GENRES_NOT_SUPPORTED = "Multiple genres are not supported by this source"
private const val MULTIPLE_STATES_NOT_SUPPORTED = "Multiple states are not supported by this source"
private const val SEARCH_NOT_SUPPORTED = "Search is not supported by this source"
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) { fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is AuthRequiredException -> resources.getString(R.string.auth_required) is AuthRequiredException -> resources.getString(R.string.auth_required)
@@ -56,8 +59,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is HttpException -> getHttpDisplayMessage(response.code, resources) is HttpException -> getHttpDisplayMessage(response.code, resources)
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources) is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
is IOException -> getDisplayMessage(message, resources) ?: localizedMessage else -> getDisplayMessage(message, resources) ?: localizedMessage
else -> localizedMessage
}.ifNullOrEmpty { }.ifNullOrEmpty {
resources.getString(R.string.error_occurred) resources.getString(R.string.error_occurred)
} }
@@ -82,7 +84,10 @@ private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String
private fun getDisplayMessage(msg: String?, resources: Resources): String? = when { private fun getDisplayMessage(msg: String?, resources: Resources): String? = when {
msg.isNullOrEmpty() -> null msg.isNullOrEmpty() -> null
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left) 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) msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
msg == MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
msg == MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
else -> null else -> null
} }

View File

@@ -0,0 +1,50 @@
package org.koitharu.kotatsu.core.util.ext
import android.net.Uri
import androidx.core.net.toFile
import okio.Source
import okio.source
import okio.use
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import java.io.File
import java.util.zip.ZipFile
const val URI_SCHEME_FILE = "file"
const val URI_SCHEME_ZIP = "file+zip"
fun Uri.exists(): Boolean = when (scheme) {
URI_SCHEME_FILE -> toFile().exists()
URI_SCHEME_ZIP -> {
val file = File(requireNotNull(schemeSpecificPart))
file.exists() && ZipFile(file).use { it.getEntry(fragment) != null }
}
else -> unsupportedUri(this)
}
fun Uri.isTargetNotEmpty(): Boolean = when (scheme) {
URI_SCHEME_FILE -> toFile().isNotEmpty()
URI_SCHEME_ZIP -> {
val file = File(requireNotNull(schemeSpecificPart))
file.exists() && ZipFile(file).use { (it.getEntry(fragment)?.size ?: 0L) != 0L }
}
else -> unsupportedUri(this)
}
fun Uri.source(): Source = when (scheme) {
URI_SCHEME_FILE -> toFile().source()
URI_SCHEME_ZIP -> {
val zip = ZipFile(schemeSpecificPart)
val entry = zip.getEntry(fragment)
zip.getInputStream(entry).source().withExtraCloseable(zip)
}
else -> unsupportedUri(this)
}
fun File.toZipUri(entryName: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryName")
private fun unsupportedUri(uri: Uri): Nothing {
throw IllegalArgumentException("Bad uri $uri: only schemes $URI_SCHEME_FILE and $URI_SCHEME_ZIP are supported")
}

View File

@@ -12,6 +12,7 @@ import androidx.core.view.children
import androidx.core.view.descendants import androidx.core.view.descendants
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.progressindicator.BaseProgressIndicator import com.google.android.material.progressindicator.BaseProgressIndicator
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
@@ -68,6 +69,10 @@ inline fun ViewPager2.doOnPageChanged(crossinline callback: (Int) -> Unit) {
val ViewPager2.recyclerView: RecyclerView? val ViewPager2.recyclerView: RecyclerView?
get() = children.firstNotNullOfOrNull { it as? RecyclerView } get() = children.firstNotNullOfOrNull { it as? RecyclerView }
fun ViewPager2.findCurrentViewHolder(): ViewHolder? {
return recyclerView?.findViewHolderForAdapterPosition(currentItem)
}
fun View.resetTransformations() { fun View.resetTransformations() {
alpha = 1f alpha = 1f
translationX = 0f translationX = 0f

View File

@@ -1,32 +0,0 @@
package org.koitharu.kotatsu.core.zip
import android.net.Uri
import androidx.annotation.WorkerThread
import androidx.collection.LruCache
import okhttp3.internal.closeQuietly
import okio.Source
import okio.source
import java.io.File
import java.util.zip.ZipFile
class ZipPool(maxSize: Int) : LruCache<String, ZipFile>(maxSize) {
override fun entryRemoved(evicted: Boolean, key: String, oldValue: ZipFile, newValue: ZipFile?) {
super.entryRemoved(evicted, key, oldValue, newValue)
oldValue.closeQuietly()
}
override fun create(key: String): ZipFile {
return ZipFile(File(key), ZipFile.OPEN_READ)
}
@Synchronized
@WorkerThread
operator fun get(uri: Uri): Source {
val zip = requireNotNull(get(uri.schemeSpecificPart)) {
"Cannot obtain zip by \"$uri\""
}
val entry = zip.getEntry(uri.fragment)
return zip.getInputStream(entry).source()
}
}

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

View File

@@ -0,0 +1,92 @@
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 okio.IOException
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))
try {
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))
} catch (e: IOException) {
local?.await()?.manga?.also { localManga ->
send(MangaDetails(localManga, null, localManga.description?.parseAsHtml(withImages = false), true))
} ?: throw e
}
}
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,91 +0,0 @@
package org.koitharu.kotatsu.details.domain
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
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,
) {
operator fun invoke(manga: Manga): Flow<DoubleManga> = flow {
var lastValue: DoubleManga? = null
var emitted = false
invokeImpl(manga).collect {
lastValue = it
if (it.any != null) {
emitted = true
emit(it)
}
}
if (!emitted) {
lastValue?.requireAny()
}
}.flowOn(Dispatchers.Default)
operator fun invoke(mangaId: Long): Flow<DoubleManga> = flow {
emit(mangaDataRepository.findMangaById(mangaId) ?: throwNFE())
}.flatMapLatest { invoke(it) }
operator fun invoke(intent: MangaIntent): Flow<DoubleManga> = flow {
emit(mangaDataRepository.resolveIntent(intent) ?: throwNFE())
}.flatMapLatest { invoke(it) }
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 invokeImpl(manga: Manga): Flow<DoubleManga> = combine(
flow { emit(null); emit(loadRemote(manga)) },
flow { emit(null); emit(loadLocal(manga)) },
) { remote, local ->
DoubleManga(
remoteManga = remote,
localManga = local,
)
}
private fun throwNFE(): Nothing = throw NotFoundException("Cannot find manga", "")
}

View File

@@ -0,0 +1,60 @@
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.os.NetworkState
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,
private val networkState: NetworkState,
) {
suspend operator fun invoke(manga: Manga): Float {
val history = database.getHistoryDao().find(manga.id) ?: return PROGRESS_NONE
val seed = if (manga.isLocal) {
localMangaRepository.getRemoteManga(manga) ?: manga
} else {
manga
}
if (!seed.isLocal && !networkState.value) {
return PROGRESS_NONE
}
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.getHistoryDao().update(
history.copy(
chapterId = chapter.id,
percent = result,
),
)
}
return result
}
}

View File

@@ -1,81 +0,0 @@
package org.koitharu.kotatsu.details.domain.model
import org.koitharu.kotatsu.core.model.findById
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 hasChapter(id: Long): Boolean {
return local?.chapters?.findById(id) != null || remote?.chapters?.findById(id) != null
}
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

@@ -2,33 +2,30 @@ package org.koitharu.kotatsu.details.ui
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.model.MangaHistory 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.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
fun mapChapters( fun MangaDetails.mapChapters(
remoteManga: Manga?,
localManga: Manga?,
history: MangaHistory?, history: MangaHistory?,
newCount: Int, newCount: Int,
branch: String?, branch: String?,
bookmarks: List<Bookmark>, bookmarks: List<Bookmark>,
): List<ChapterListItem> { ): List<ChapterListItem> {
val remoteChapters = remoteManga?.getChapters(branch).orEmpty() val remoteChapters = chapters[branch].orEmpty()
val localChapters = localManga?.getChapters(branch).orEmpty() val localChapters = local?.manga?.getChapters(branch).orEmpty()
if (remoteChapters.isEmpty() && localChapters.isEmpty()) { if (remoteChapters.isEmpty() && localChapters.isEmpty()) {
return emptyList() return emptyList()
} }
val bookmarked = bookmarks.mapToSet { it.chapterId } val bookmarked = bookmarks.mapToSet { it.chapterId }
val currentId = history?.chapterId ?: 0L val currentId = history?.chapterId ?: 0L
val newFrom = if (newCount == 0 || remoteChapters.isEmpty()) Int.MAX_VALUE else remoteChapters.size - newCount val newFrom = if (newCount == 0 || remoteChapters.isEmpty()) Int.MAX_VALUE else remoteChapters.size - newCount
val chaptersSize = maxOf(remoteChapters.size, localChapters.size) val ids = buildSet(maxOf(remoteChapters.size, localChapters.size)) {
val ids = buildSet(chaptersSize) {
remoteChapters.mapTo(this) { it.id } remoteChapters.mapTo(this) { it.id }
localChapters.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()) { val localMap = if (localChapters.isNotEmpty()) {
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id } localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
} else { } else {
@@ -40,7 +37,7 @@ fun mapChapters(
if (chapter.id == currentId) { if (chapter.id == currentId) {
isUnread = true isUnread = true
} }
result += chapter.toListItem( result += (local ?: chapter).toListItem(
isCurrent = chapter.id == currentId, isCurrent = chapter.id == currentId,
isUnread = isUnread, isUnread = isUnread,
isNew = isUnread && result.size >= newFrom, isNew = isUnread && result.size >= newFrom,
@@ -57,7 +54,7 @@ fun mapChapters(
isCurrent = chapter.id == currentId, isCurrent = chapter.id == currentId,
isUnread = isUnread, isUnread = isUnread,
isNew = false, isNew = false,
isDownloaded = remoteManga != null, isDownloaded = !isLocal,
isBookmarked = chapter.id in bookmarked, 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.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator 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.doOnExpansionsChanged
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
@@ -75,7 +74,6 @@ class DetailsActivity :
@Inject @Inject
lateinit var appShortcutManager: AppShortcutManager lateinit var appShortcutManager: AppShortcutManager
private lateinit var viewBadge: ViewBadge
private var buttonTip: WeakReference<ButtonTip>? = null private var buttonTip: WeakReference<ButtonTip>? = null
private val viewModel: DetailsViewModel by viewModels() private val viewModel: DetailsViewModel by viewModels()
@@ -92,7 +90,6 @@ class DetailsActivity :
viewBinding.buttonRead.setOnLongClickListener(this) viewBinding.buttonRead.setOnLongClickListener(this)
viewBinding.buttonRead.setOnContextClickListenerCompat(this) viewBinding.buttonRead.setOnContextClickListenerCompat(this)
viewBinding.buttonDropdown.setOnClickListener(this) viewBinding.buttonDropdown.setOnClickListener(this)
viewBadge = ViewBadge(viewBinding.buttonRead, this)
if (viewBinding.layoutBottom != null) { if (viewBinding.layoutBottom != null) {
val behavior = BottomSheetBehavior.from(checkNotNull(viewBinding.layoutBottom)) val behavior = BottomSheetBehavior.from(checkNotNull(viewBinding.layoutBottom))
@@ -113,7 +110,6 @@ class DetailsActivity :
onBackPressedDispatcher.addCallback(chaptersMenuProvider) onBackPressedDispatcher.addCallback(chaptersMenuProvider)
viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated) viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated)
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved) viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
viewModel.onError.observeEvent( viewModel.onError.observeEvent(
this, this,
@@ -139,16 +135,18 @@ class DetailsActivity :
} }
viewModel.isChaptersReversed.observe( viewModel.isChaptersReversed.observe(
this, this,
MenuInvalidator(viewBinding.toolbarChapters ?: this) MenuInvalidator(viewBinding.toolbarChapters ?: this),
) )
viewModel.favouriteCategories.observe(this, MenuInvalidator(this)) val menuInvalidator = MenuInvalidator(this)
viewModel.favouriteCategories.observe(this, menuInvalidator)
viewModel.remoteManga.observe(this, menuInvalidator)
viewModel.branches.observe(this) { viewModel.branches.observe(this) {
viewBinding.buttonDropdown.isVisible = it.size > 1 viewBinding.buttonDropdown.isVisible = it.size > 1
} }
viewModel.chapters.observe(this, PrefetchObserver(this)) viewModel.chapters.observe(this, PrefetchObserver(this))
viewModel.onDownloadStarted.observeEvent( viewModel.onDownloadStarted.observeEvent(
this, this,
DownloadStartedObserver(viewBinding.containerDetails) DownloadStartedObserver(viewBinding.containerDetails),
) )
addMenuProvider( addMenuProvider(
@@ -255,7 +253,7 @@ class DetailsActivity :
window.setNavigationBarTransparentCompat( window.setNavigationBarTransparentCompat(
this, this,
viewBinding.layoutBottom?.elevation ?: 0f, viewBinding.layoutBottom?.elevation ?: 0f,
0.9f 0.9f,
) )
} }
viewBinding.cardChapters?.updateLayoutParams<MarginLayoutParams> { viewBinding.cardChapters?.updateLayoutParams<MarginLayoutParams> {
@@ -281,24 +279,20 @@ class DetailsActivity :
info.currentChapter >= 0 -> getString( info.currentChapter >= 0 -> getString(
R.string.chapter_d_of_d, R.string.chapter_d_of_d,
info.currentChapter + 1, info.currentChapter + 1,
info.totalChapters info.totalChapters,
) )
info.totalChapters == 0 -> getString(R.string.no_chapters) info.totalChapters == 0 -> getString(R.string.no_chapters)
else -> resources.getQuantityString( else -> resources.getQuantityString(
R.plurals.chapters, R.plurals.chapters,
info.totalChapters, info.totalChapters,
info.totalChapters info.totalChapters,
) )
} }
viewBinding.toolbarChapters?.title = text viewBinding.toolbarChapters?.title = text
viewBinding.textViewTitle?.text = text viewBinding.textViewTitle?.text = text
} }
private fun onNewChaptersChanged(newChapters: Int) {
viewBadge.counter = newChapters
}
private fun showBranchPopupMenu(v: View) { private fun showBranchPopupMenu(v: View) {
val menu = PopupMenu(v.context, v) val menu = PopupMenu(v.context, v)
val branches = viewModel.branches.value val branches = viewModel.branches.value
@@ -311,8 +305,8 @@ class DetailsActivity :
ForegroundColorSpan( ForegroundColorSpan(
v.context.getThemeColor( v.context.getThemeColor(
android.R.attr.textColorSecondary, android.R.attr.textColorSecondary,
Color.LTGRAY Color.LTGRAY,
) ),
), ),
RelativeSizeSpan(0.74f), RelativeSizeSpan(0.74f),
) { ) {

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.details.ui package org.koitharu.kotatsu.details.ui
import android.os.Bundle import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.transition.TransitionManager import android.transition.TransitionManager
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@@ -11,6 +10,9 @@ import android.widget.Toast
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.text.buildSpannedString
import androidx.core.text.color
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
@@ -21,6 +23,7 @@ import coil.request.SuccessResult
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
@@ -37,6 +40,7 @@ import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.crossfade import org.koitharu.kotatsu.core.util.ext.crossfade
import org.koitharu.kotatsu.core.util.ext.drawableTop import org.koitharu.kotatsu.core.util.ext.drawableTop
import org.koitharu.kotatsu.core.util.ext.enqueueWith 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.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.isTextTruncated import org.koitharu.kotatsu.core.util.ext.isTextTruncated
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
@@ -70,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.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.search.ui.SearchActivity
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint @AndroidEntryPoint
class DetailsFragment : class DetailsFragment :
BaseFragment<FragmentDetailsBinding>(), BaseFragment<FragmentDetailsBinding>(),
View.OnClickListener, View.OnClickListener,
ChipsView.OnChipClickListener, ChipsView.OnChipClickListener,
OnListItemClickListener<Bookmark>, ViewTreeObserver.OnDrawListener { OnListItemClickListener<Bookmark>, ViewTreeObserver.OnDrawListener, View.OnLayoutChangeListener {
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
@@ -100,8 +105,9 @@ class DetailsFragment :
binding.buttonScrobblingMore.setOnClickListener(this) binding.buttonScrobblingMore.setOnClickListener(this)
binding.buttonRelatedMore.setOnClickListener(this) binding.buttonRelatedMore.setOnClickListener(this)
binding.infoLayout.textViewSource.setOnClickListener(this) binding.infoLayout.textViewSource.setOnClickListener(this)
binding.textViewDescription.addOnLayoutChangeListener(this)
binding.textViewDescription.viewTreeObserver.addOnDrawListener(this) binding.textViewDescription.viewTreeObserver.addOnDrawListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance() binding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
binding.chipsTags.onChipClickListener = this binding.chipsTags.onChipClickListener = this
binding.recyclerViewRelated.addItemDecoration( binding.recyclerViewRelated.addItemDecoration(
SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing)), SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing)),
@@ -113,9 +119,9 @@ class DetailsFragment :
viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged) viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)
viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged) viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged) viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged)
viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged) viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged)
viewModel.relatedManga.observe(viewLifecycleOwner, ::onRelatedMangaChanged) viewModel.relatedManga.observe(viewLifecycleOwner, ::onRelatedMangaChanged)
combine(viewModel.chapters, viewModel.newChaptersCount, ::Pair).observe(viewLifecycleOwner, ::onChaptersChanged)
} }
override fun onItemClick(item: Bookmark, view: View) { override fun onItemClick(item: Bookmark, view: View) {
@@ -145,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) { private fun onMangaUpdated(manga: Manga) {
with(requireViewBinding()) { with(requireViewBinding()) {
// Main // Main
@@ -160,21 +182,27 @@ class DetailsFragment :
} }
when (manga.state) { when (manga.state) {
MangaState.FINISHED -> { MangaState.FINISHED -> infoLayout.textViewState.apply {
infoLayout.textViewState.apply { textAndVisible = resources.getString(R.string.state_finished)
textAndVisible = resources.getString(R.string.state_finished) drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_finished)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_finished)
}
} }
MangaState.ONGOING -> { MangaState.ONGOING -> infoLayout.textViewState.apply {
infoLayout.textViewState.apply { textAndVisible = resources.getString(R.string.state_ongoing)
textAndVisible = resources.getString(R.string.state_ongoing) drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_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)
}
MangaState.PAUSED -> infoLayout.textViewState.apply {
textAndVisible = resources.getString(R.string.state_paused)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_action_pause)
}
null -> infoLayout.textViewState.isVisible = false
} }
if (manga.source == MangaSource.LOCAL) { if (manga.source == MangaSource.LOCAL) {
infoLayout.textViewSource.isVisible = false infoLayout.textViewSource.isVisible = false
@@ -190,14 +218,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 val infoLayout = requireViewBinding().infoLayout
if (chapters.isNullOrEmpty()) { if (chapters.isNullOrEmpty()) {
infoLayout.textViewChapters.isVisible = false infoLayout.textViewChapters.isVisible = false
} else { } else {
val count = chapters.countChaptersByBranch() val count = chapters.countChaptersByBranch()
infoLayout.textViewChapters.isVisible = true 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(')')
}
}
}
} }
} }
@@ -208,7 +250,6 @@ class DetailsFragment :
} else { } else {
tv.text = description tv.text = description
} }
requireViewBinding().buttonDescriptionMore.isVisible = tv.isTextTruncated
} }
private fun onLocalSizeChanged(size: Long) { private fun onLocalSizeChanged(size: Long) {

View File

@@ -19,7 +19,7 @@ import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteSheet import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
@@ -42,6 +42,7 @@ class DetailsMenuProvider(
menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL 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_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable 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( menu.findItem(R.id.action_favourite).setIcon(
if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline, if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline,
) )
@@ -62,7 +63,7 @@ class DetailsMenuProvider(
R.id.action_favourite -> { R.id.action_favourite -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
FavouriteSheet.show(activity.supportFragmentManager, it) FavoriteSheet.show(activity.supportFragmentManager, it)
} }
} }
@@ -88,6 +89,12 @@ class DetailsMenuProvider(
} }
} }
R.id.action_online -> {
viewModel.remoteManga.value?.let {
activity.startActivity(DetailsActivity.newIntent(activity, it))
}
}
R.id.action_related -> { R.id.action_related -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
activity.startActivity(MultiSearchActivity.newIntent(activity, it.title)) activity.startActivity(MultiSearchActivity.newIntent(activity, it.title))

View File

@@ -1,12 +1,5 @@
package org.koitharu.kotatsu.details.ui 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.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -17,22 +10,21 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.getPreferredBranch 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.parser.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
@@ -40,17 +32,15 @@ import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call 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.computeSize
import org.koitharu.kotatsu.core.util.ext.onFirst import org.koitharu.kotatsu.core.util.ext.onEachWhile
import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.details.domain.BranchComparator import org.koitharu.kotatsu.details.domain.BranchComparator
import org.koitharu.kotatsu.details.domain.DetailsInteractor 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.RelatedMangaUseCase
import org.koitharu.kotatsu.details.domain.model.DoubleManga
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.model.MangaBranch import org.koitharu.kotatsu.details.ui.model.MangaBranch
@@ -74,22 +64,19 @@ class DetailsViewModel @Inject constructor(
private val bookmarksRepository: BookmarksRepository, private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>, private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
private val imageGetter: Html.ImageGetter,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>, @LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
private val downloadScheduler: DownloadWorker.Scheduler, private val downloadScheduler: DownloadWorker.Scheduler,
private val interactor: DetailsInteractor, private val interactor: DetailsInteractor,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
private val doubleMangaLoadUseCase: DoubleMangaLoadUseCase,
private val relatedMangaUseCase: RelatedMangaUseCase, private val relatedMangaUseCase: RelatedMangaUseCase,
private val extraProvider: ListExtraProvider, private val extraProvider: ListExtraProvider,
networkState: NetworkState, private val detailsLoadUseCase: DetailsLoadUseCase,
private val progressUpdateUseCase: ProgressUpdateUseCase,
) : BaseViewModel() { ) : BaseViewModel() {
private val intent = MangaIntent(savedStateHandle) private val intent = MangaIntent(savedStateHandle)
private val mangaId = intent.mangaId private val mangaId = intent.mangaId
private val doubleManga: MutableStateFlow<DoubleManga?> =
MutableStateFlow(intent.manga?.let { DoubleManga(it) })
private var loadingJob: Job private var loadingJob: Job
val onShowToast = MutableEventFlow<Int>() val onShowToast = MutableEventFlow<Int>()
@@ -97,8 +84,9 @@ class DetailsViewModel @Inject constructor(
val onSelectChapter = MutableEventFlow<Long>() val onSelectChapter = MutableEventFlow<Long>()
val onDownloadStarted = MutableEventFlow<Unit>() val onDownloadStarted = MutableEventFlow<Unit>()
val manga = doubleManga.map { it?.any } val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) })
.stateIn(viewModelScope, SharingStarted.Eagerly, doubleManga.value?.any) val manga = details.map { x -> x?.toManga() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val history = historyRepository.observeOne(mangaId) val history = historyRepository.observeOne(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
@@ -106,8 +94,15 @@ class DetailsViewModel @Inject constructor(
val favouriteCategories = interactor.observeIsFavourite(mangaId) val favouriteCategories = interactor.observeIsFavourite(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
val newChaptersCount = interactor.observeNewChapters(mangaId) val remoteManga = MutableStateFlow<Manga?>(null)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
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("") private val chaptersQuery = MutableStateFlow("")
val selectedBranch = MutableStateFlow<String?>(null) val selectedBranch = MutableStateFlow<String?>(null)
@@ -135,28 +130,17 @@ class DetailsViewModel @Inject constructor(
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList()) if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList()) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
val localSize = doubleManga val localSize = details
.map { .map { it?.local }
val local = it?.local .distinctUntilChanged()
if (local != null) { .map { local ->
val file = local.url.toUri().toFileOrNull() local?.file?.computeSize() ?: 0L
file?.computeSize() ?: 0L
} else {
0L
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(), 0) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(), 0)
val description = manga @Deprecated("")
.distinctUntilChangedBy { it?.description.orEmpty() } val description = details
.transformLatest { .map { it?.description }
val description = it?.description .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, null)
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)
val onMangaRemoved = MutableEventFlow<Manga>() val onMangaRemoved = MutableEventFlow<Manga>()
val isScrobblingAvailable: Boolean val isScrobblingAvailable: Boolean
@@ -165,53 +149,41 @@ class DetailsViewModel @Inject constructor(
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId) val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val relatedManga: StateFlow<List<MangaItemModel>> = doubleManga.map { val relatedManga: StateFlow<List<MangaItemModel>> = manga.mapLatest {
it?.remote if (it != null && settings.isRelatedMangaEnabled) {
}.distinctUntilChangedBy { it?.id } relatedMangaUseCase.invoke(it)?.toUi(ListMode.GRID, extraProvider).orEmpty()
.mapLatest { } else {
if (it != null && settings.isRelatedMangaEnabled) { emptyList()
relatedMangaUseCase.invoke(it)?.toUi(ListMode.GRID, extraProvider).orEmpty()
} else {
emptyList()
}
} }
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
val branches: StateFlow<List<MangaBranch>> = combine( val branches: StateFlow<List<MangaBranch>> = combine(
doubleManga, details,
selectedBranch, selectedBranch,
) { m, b -> ) { m, b ->
val chapters = m?.chapters (m?.chapters ?: return@combine emptyList())
if (chapters.isNullOrEmpty()) return@combine emptyList()
chapters.groupBy { x -> x.branch }
.map { x -> MangaBranch(x.key, x.value.size, x.key == b) } .map { x -> MangaBranch(x.key, x.value.size, x.key == b) }
.sortedWith(BranchComparator()) .sortedWith(BranchComparator())
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val isChaptersEmpty: StateFlow<Boolean> = combine( val isChaptersEmpty: StateFlow<Boolean> = details.map {
doubleManga, it != null && it.isLoaded && it.allChapters.isEmpty()
isLoading,
) { manga, loading ->
manga?.any != null && manga.chapters.isNullOrEmpty() && !loading
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
val chapters = combine( val chapters = combine(
combine( combine(
doubleManga, details,
history, history,
selectedBranch, selectedBranch,
newChaptersCount, newChaptersCount,
bookmarks, bookmarks,
networkState, ) { manga, history, branch, news, bookmarks ->
) { manga, history, branch, news, bookmarks, isOnline -> manga?.mapChapters(
mapChapters(
manga?.remote?.takeIf { isOnline },
manga?.local,
history, history,
news, news,
branch, branch,
bookmarks, bookmarks,
) ).orEmpty()
}, },
isChaptersReversed, isChaptersReversed,
chaptersQuery, chaptersQuery,
@@ -234,6 +206,17 @@ class DetailsViewModel @Inject constructor(
onShowTip.call(Unit) 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.findRemote(manga.toManga())
}
} }
fun reload() { fun reload() {
@@ -242,7 +225,7 @@ class DetailsViewModel @Inject constructor(
} }
fun deleteLocal() { fun deleteLocal() {
val m = doubleManga.value?.local val m = details.value?.local?.manga
if (m == null) { if (m == null) {
onShowToast.call(R.string.file_not_found) onShowToast.call(R.string.file_not_found)
return return
@@ -295,13 +278,13 @@ class DetailsViewModel @Inject constructor(
fun markChapterAsCurrent(chapterId: Long) { fun markChapterAsCurrent(chapterId: Long) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val manga = checkNotNull(doubleManga.value) val manga = checkNotNull(details.value)
val chapters = checkNotNull(manga.filterChapters(selectedBranchValue).chapters) val chapters = checkNotNull(manga.chapters[selectedBranchValue])
val chapterIndex = chapters.indexOfFirst { it.id == chapterId } val chapterIndex = chapters.indexOfFirst { it.id == chapterId }
check(chapterIndex in chapters.indices) { "Chapter not found" } check(chapterIndex in chapters.indices) { "Chapter not found" }
val percent = chapterIndex / chapters.size.toFloat() val percent = chapterIndex / chapters.size.toFloat()
historyRepository.addOrUpdate( historyRepository.addOrUpdate(
manga = manga.requireAny(), manga = manga.toManga(),
chapterId = chapterId, chapterId = chapterId,
page = 0, page = 0,
scroll = 0, scroll = 0,
@@ -313,7 +296,7 @@ class DetailsViewModel @Inject constructor(
fun download(chaptersIds: Set<Long>?) { fun download(chaptersIds: Set<Long>?) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
downloadScheduler.schedule( downloadScheduler.schedule(
doubleManga.requireValue().requireAny(), details.requireValue().toManga(),
chaptersIds, chaptersIds,
) )
onDownloadStarted.call(Unit) onDownloadStarted.call(Unit)
@@ -333,14 +316,18 @@ class DetailsViewModel @Inject constructor(
} }
private fun doLoad() = launchLoadingJob(Dispatchers.Default) { private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
doubleMangaLoadUseCase.invoke(intent) detailsLoadUseCase.invoke(intent)
.onFirst { .onEachWhile {
val manga = it.requireAny() if (it.allChapters.isEmpty()) {
return@onEachWhile false
}
val manga = it.toManga()
// find default branch // find default branch
val hist = historyRepository.getOne(manga) val hist = historyRepository.getOne(manga)
selectedBranch.value = manga.getPreferredBranch(hist) selectedBranch.value = manga.getPreferredBranch(hist)
true
}.collect { }.collect {
doubleManga.value = it details.value = it
} }
} }
@@ -356,21 +343,12 @@ class DetailsViewModel @Inject constructor(
private suspend fun onDownloadComplete(downloadedManga: LocalManga?) { private suspend fun onDownloadComplete(downloadedManga: LocalManga?) {
downloadedManga ?: return downloadedManga ?: return
launchJob { launchJob {
doubleManga.update { details.update {
interactor.updateLocal(it, downloadedManga) 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? { private fun getScrobbler(index: Int): Scrobbler? {
val info = scrobblingInfo.value.getOrNull(index) val info = scrobblingInfo.value.getOrNull(index)
val scrobbler = if (info != null) { 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.R
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener 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.drawableStart
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
@@ -47,7 +48,6 @@ fun chapterListItemAD(
} }
binding.imageViewBookmarked.isVisible = item.isBookmarked binding.imageViewBookmarked.isVisible = item.isBookmarked
binding.imageViewDownloaded.isVisible = item.isDownloaded binding.imageViewDownloaded.isVisible = item.isDownloaded
// binding.imageViewNew.isVisible = item.isNew
binding.textViewTitle.drawableStart = if (item.isNew) { binding.textViewTitle.drawableStart = if (item.isNew) {
ContextCompat.getDrawable(context, R.drawable.ic_new) ContextCompat.getDrawable(context, R.drawable.ic_new)
} else { } else {

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.details.ui.scrobbling
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
@@ -12,6 +11,7 @@ import android.widget.RatingBar
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import coil.ImageLoader import coil.ImageLoader
@@ -71,7 +71,7 @@ class ScrobblingInfoSheet :
binding.ratingBar.onRatingBarChangeListener = this binding.ratingBar.onRatingBarChangeListener = this
binding.buttonMenu.setOnClickListener(this) binding.buttonMenu.setOnClickListener(this)
binding.imageViewCover.setOnClickListener(this) binding.imageViewCover.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance() binding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
menu = PopupMenu(binding.root.context, binding.buttonMenu).apply { menu = PopupMenu(binding.root.context, binding.buttonMenu).apply {
inflate(R.menu.opt_scrobbling) inflate(R.menu.opt_scrobbling)

View File

@@ -18,7 +18,7 @@ data class DownloadState(
val currentPage: Int = 0, val currentPage: Int = 0,
val eta: Long = -1L, val eta: Long = -1L,
val localManga: LocalManga? = null, val localManga: LocalManga? = null,
val downloadedChapters: LongArray = LongArray(0), val downloadedChapters: Int = 0,
val timestamp: Long = System.currentTimeMillis(), val timestamp: Long = System.currentTimeMillis(),
) { ) {
@@ -41,61 +41,17 @@ data class DownloadState(
.putLong(DATA_ETA, eta) .putLong(DATA_ETA, eta)
.putLong(DATA_TIMESTAMP, timestamp) .putLong(DATA_TIMESTAMP, timestamp)
.putString(DATA_ERROR, error) .putString(DATA_ERROR, error)
.putLongArray(DATA_CHAPTERS, downloadedChapters) .putInt(DATA_CHAPTERS, downloadedChapters)
.putBoolean(DATA_INDETERMINATE, isIndeterminate) .putBoolean(DATA_INDETERMINATE, isIndeterminate)
.putBoolean(DATA_PAUSED, isPaused) .putBoolean(DATA_PAUSED, isPaused)
.build() .build()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DownloadState
if (manga != other.manga) return false
if (isIndeterminate != other.isIndeterminate) return false
if (isPaused != other.isPaused) return false
if (isStopped != other.isStopped) return false
if (error != other.error) return false
if (totalChapters != other.totalChapters) return false
if (currentChapter != other.currentChapter) return false
if (totalPages != other.totalPages) return false
if (currentPage != other.currentPage) return false
if (eta != other.eta) return false
if (localManga != other.localManga) return false
if (!downloadedChapters.contentEquals(other.downloadedChapters)) return false
if (timestamp != other.timestamp) return false
if (max != other.max) return false
if (progress != other.progress) return false
return percent == other.percent
}
override fun hashCode(): Int {
var result = manga.hashCode()
result = 31 * result + isIndeterminate.hashCode()
result = 31 * result + isPaused.hashCode()
result = 31 * result + isStopped.hashCode()
result = 31 * result + (error?.hashCode() ?: 0)
result = 31 * result + totalChapters
result = 31 * result + currentChapter
result = 31 * result + totalPages
result = 31 * result + currentPage
result = 31 * result + eta.hashCode()
result = 31 * result + (localManga?.hashCode() ?: 0)
result = 31 * result + downloadedChapters.contentHashCode()
result = 31 * result + timestamp.hashCode()
result = 31 * result + max
result = 31 * result + progress
result = 31 * result + percent.hashCode()
return result
}
companion object { companion object {
private const val DATA_MANGA_ID = "manga_id" private const val DATA_MANGA_ID = "manga_id"
private const val DATA_MAX = "max" private const val DATA_MAX = "max"
private const val DATA_PROGRESS = "progress" private const val DATA_PROGRESS = "progress"
private const val DATA_CHAPTERS = "chapter" private const val DATA_CHAPTERS = "chapter_cnt"
private const val DATA_ETA = "eta" private const val DATA_ETA = "eta"
private const val DATA_TIMESTAMP = "timestamp" private const val DATA_TIMESTAMP = "timestamp"
private const val DATA_ERROR = "error" private const val DATA_ERROR = "error"
@@ -118,6 +74,6 @@ data class DownloadState(
fun getTimestamp(data: Data): Date = Date(data.getLong(DATA_TIMESTAMP, 0L)) fun getTimestamp(data: Data): Date = Date(data.getLong(DATA_TIMESTAMP, 0L))
fun getDownloadedChapters(data: Data): LongArray = data.getLongArray(DATA_CHAPTERS) ?: LongArray(0) fun getDownloadedChapters(data: Data): Int = data.getInt(DATA_CHAPTERS, 0)
} }
} }

View File

@@ -1,18 +1,23 @@
package org.koitharu.kotatsu.download.ui.list package org.koitharu.kotatsu.download.ui.list
import android.transition.TransitionManager
import android.view.View import android.view.View
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.work.WorkInfo import androidx.work.WorkInfo
import coil.ImageLoader import coil.ImageLoader
import coil.request.SuccessResult
import coil.util.CoilUtils
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.util.ext.enqueueWith 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.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemDownloadBinding import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
@@ -25,6 +30,7 @@ fun downloadItemAD(
) { ) {
val percentPattern = context.resources.getString(R.string.percent_string_pattern) val percentPattern = context.resources.getString(R.string.percent_string_pattern)
// val expandIcon = ContextCompat.getDrawable(context, R.drawable.ic_expand_collapse)
val clickListener = object : View.OnClickListener, View.OnLongClickListener { val clickListener = object : View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View) { override fun onClick(v: View) {
@@ -47,16 +53,24 @@ fun downloadItemAD(
itemView.setOnLongClickListener(clickListener) itemView.setOnLongClickListener(clickListener)
bind { payloads -> bind { payloads ->
binding.textViewTitle.text = item.manga.title if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads && context.isAnimationsEnabled) {
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.apply { TransitionManager.beginDelayedTransition(binding.constraintLayout)
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
transformations(TrimTransformation())
source(item.manga.source)
enqueueWith(coil)
} }
binding.textViewTitle.text = item.manga.title
if ((CoilUtils.result(binding.imageViewCover) as? SuccessResult)?.memoryCacheKey != item.coverCacheKey) {
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.apply {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
transformations(TrimTransformation())
memoryCacheKey(item.coverCacheKey)
source(item.manga.source)
enqueueWith(coil)
}
}
// binding.textViewTitle.isChecked = item.isExpanded
// binding.textViewTitle.drawableEnd = if (item.isExpandable) expandIcon else null
when (item.workState) { when (item.workState) {
WorkInfo.State.ENQUEUED, WorkInfo.State.ENQUEUED,
WorkInfo.State.BLOCKED -> { WorkInfo.State.BLOCKED -> {
@@ -94,11 +108,11 @@ fun downloadItemAD(
binding.progressBar.isVisible = false binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false binding.textViewPercent.isVisible = false
if (item.totalChapters > 0) { if (item.chaptersDownloaded > 0) {
binding.textViewDetails.text = context.resources.getQuantityString( binding.textViewDetails.text = context.resources.getQuantityString(
R.plurals.chapters, R.plurals.chapters,
item.totalChapters, item.chaptersDownloaded,
item.totalChapters, item.chaptersDownloaded,
) )
binding.textViewDetails.isVisible = true binding.textViewDetails.isVisible = true
} else { } else {

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.download.ui.list
import android.text.format.DateUtils import android.text.format.DateUtils
import androidx.work.WorkInfo import androidx.work.WorkInfo
import coil.memory.MemoryCache
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date import java.util.Date
@@ -15,12 +17,15 @@ data class DownloadItemModel(
val manga: Manga, val manga: Manga,
val error: String?, val error: String?,
val max: Int, val max: Int,
val totalChapters: Int,
val progress: Int, val progress: Int,
val eta: Long, val eta: Long,
val timestamp: Date, val timestamp: Date,
val chaptersDownloaded: Int,
val isExpanded: Boolean,
) : ListModel, Comparable<DownloadItemModel> { ) : ListModel, Comparable<DownloadItemModel> {
val coverCacheKey = MemoryCache.Key(manga.coverUrl, mapOf("dl" to "1"))
val percent: Float val percent: Float
get() = if (max > 0) progress / max.toFloat() else 0f get() = if (max > 0) progress / max.toFloat() else 0f
@@ -33,6 +38,9 @@ data class DownloadItemModel(
val canResume: Boolean val canResume: Boolean
get() = workState == WorkInfo.State.RUNNING && isPaused get() = workState == WorkInfo.State.RUNNING && isPaused
val isExpandable: Boolean
get() = false // TODO
fun getEtaString(): CharSequence? = if (hasEta) { fun getEtaString(): CharSequence? = if (hasEta) {
DateUtils.getRelativeTimeSpanString( DateUtils.getRelativeTimeSpanString(
eta, eta,
@@ -51,17 +59,10 @@ data class DownloadItemModel(
return other is DownloadItemModel && other.id == id return other is DownloadItemModel && other.id == id
} }
override fun getChangePayload(previousState: ListModel): Any? { override fun getChangePayload(previousState: ListModel): Any? = when {
return when (previousState) { previousState !is DownloadItemModel -> super.getChangePayload(previousState)
is DownloadItemModel -> { workState != previousState.workState -> null
if (workState == previousState.workState) { isExpanded != previousState.isExpanded -> ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
Unit else -> ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED
} else {
null
}
}
else -> super.getChangePayload(previousState)
}
} }
} }

View File

@@ -15,6 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
@@ -53,6 +54,7 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
addItemDecoration(decoration) addItemDecoration(decoration)
adapter = downloadsAdapter adapter = downloadsAdapter
selectionController.attachToRecyclerView(this) selectionController.attachToRecyclerView(this)
RecyclerScrollKeeper(this).attach()
} }
addMenuProvider(DownloadsMenuProvider(this, viewModel)) addMenuProvider(DownloadsMenuProvider(this, viewModel))
viewModel.items.observe(this) { viewModel.items.observe(this) {
@@ -82,7 +84,11 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
if (selectionController.onItemClick(item.id.mostSignificantBits)) { if (selectionController.onItemClick(item.id.mostSignificantBits)) {
return 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 { override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean {

View File

@@ -8,15 +8,19 @@ import androidx.work.Data
import androidx.work.WorkInfo import androidx.work.WorkInfo
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaDataRepository 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.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
@@ -31,6 +35,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Date import java.util.Date
import java.util.LinkedList import java.util.LinkedList
import java.util.UUID import java.util.UUID
@@ -41,13 +46,18 @@ import javax.inject.Inject
class DownloadsViewModel @Inject constructor( class DownloadsViewModel @Inject constructor(
private val workScheduler: DownloadWorker.Scheduler, private val workScheduler: DownloadWorker.Scheduler,
private val mangaDataRepository: MangaDataRepository, private val mangaDataRepository: MangaDataRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
) : BaseViewModel() { ) : BaseViewModel() {
private val mangaCache = LongSparseArray<Manga>() private val mangaCache = LongSparseArray<Manga>()
private val cacheMutex = Mutex() private val cacheMutex = Mutex()
private val works = workScheduler.observeWorks() private val expanded = MutableStateFlow(emptySet<UUID>())
.mapLatest { it.toDownloadsList() } private val works = combine(
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) workScheduler.observeWorks(),
expanded,
) { list, exp ->
list.toDownloadsList(exp)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val onActionDone = MutableEventFlow<ReversibleAction>() val onActionDone = MutableEventFlow<ReversibleAction>()
@@ -169,11 +179,21 @@ class DownloadsViewModel @Inject constructor(
it.id.mostSignificantBits it.id.mostSignificantBits
} ?: emptySet() } ?: 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()) { if (isEmpty()) {
return emptyList() return emptyList()
} }
val list = mapNotNullTo(ArrayList(size)) { it.toUiModel() } val list = mapNotNullTo(ArrayList(size)) { it.toUiModel(it.id in exp) }
list.sortByDescending { it.timestamp } list.sortByDescending { it.timestamp }
return list return list
} }
@@ -213,7 +233,7 @@ class DownloadsViewModel @Inject constructor(
return destination 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 workData = if (outputData == Data.EMPTY) progress else outputData
val mangaId = DownloadState.getMangaId(workData) val mangaId = DownloadState.getMangaId(workData)
if (mangaId == 0L) return null if (mangaId == 0L) return null
@@ -229,7 +249,8 @@ class DownloadsViewModel @Inject constructor(
progress = DownloadState.getProgress(workData), progress = DownloadState.getProgress(workData),
eta = DownloadState.getEta(workData), eta = DownloadState.getEta(workData),
timestamp = DownloadState.getTimestamp(workData), timestamp = DownloadState.getTimestamp(workData),
totalChapters = DownloadState.getDownloadedChapters(workData).size, chaptersDownloaded = DownloadState.getDownloadedChapters(workData),
isExpanded = isExpanded,
) )
} }
@@ -261,8 +282,16 @@ class DownloadsViewModel @Inject constructor(
} }
return cacheMutex.withLock { return cacheMutex.withLock {
mangaCache.getOrElse(mangaId) { 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

@@ -38,6 +38,7 @@ import okio.buffer
import okio.sink import okio.sink
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
@@ -46,7 +47,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.Throttler import org.koitharu.kotatsu.core.util.Throttler
import org.koitharu.kotatsu.core.util.ext.awaitFinishedWorkInfosByTag import org.koitharu.kotatsu.core.util.ext.awaitFinishedWorkInfosByTag
import org.koitharu.kotatsu.core.util.ext.awaitUpdateWork import org.koitharu.kotatsu.core.util.ext.awaitUpdateWork
import org.koitharu.kotatsu.core.util.ext.awaitWorkInfoById
import org.koitharu.kotatsu.core.util.ext.awaitWorkInfosByTag import org.koitharu.kotatsu.core.util.ext.awaitWorkInfosByTag
import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.deleteWork import org.koitharu.kotatsu.core.util.ext.deleteWork
@@ -105,11 +105,12 @@ class DownloadWorker @AssistedInject constructor(
setForeground(getForegroundInfo()) setForeground(getForegroundInfo())
val mangaId = inputData.getLong(MANGA_ID, 0L) val mangaId = inputData.getLong(MANGA_ID, 0L)
val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure() val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure()
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
val downloadedIds = getDoneChapters()
lastPublishedState = DownloadState(manga, isIndeterminate = true) lastPublishedState = DownloadState(manga, isIndeterminate = true)
publishState(DownloadState(manga, isIndeterminate = true))
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
val downloadedIds = getDoneChapters(manga)
return try { return try {
downloadMangaImpl(chaptersIds, downloadedIds) downloadMangaImpl(manga, chaptersIds, downloadedIds)
Result.success(currentState.toWorkData()) Result.success(currentState.toWorkData())
} catch (e: CancellationException) { } catch (e: CancellationException) {
withContext(NonCancellable) { withContext(NonCancellable) {
@@ -147,10 +148,11 @@ class DownloadWorker @AssistedInject constructor(
} }
private suspend fun downloadMangaImpl( private suspend fun downloadMangaImpl(
subject: Manga,
includedIds: LongArray?, includedIds: LongArray?,
excludedIds: LongArray, excludedIds: Set<Long>,
) { ) {
var manga = currentState.manga var manga = subject
val chaptersToSkip = excludedIds.toMutableSet() val chaptersToSkip = excludedIds.toMutableSet()
withMangaLock(manga) { withMangaLock(manga) {
ContextCompat.registerReceiver( ContextCompat.registerReceiver(
@@ -180,11 +182,7 @@ class DownloadWorker @AssistedInject constructor(
val chapters = getChapters(mangaDetails, includedIds) val chapters = getChapters(mangaDetails, includedIds)
for ((chapterIndex, chapter) in chapters.withIndex()) { for ((chapterIndex, chapter) in chapters.withIndex()) {
if (chaptersToSkip.remove(chapter.id)) { if (chaptersToSkip.remove(chapter.id)) {
publishState( publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1))
currentState.copy(
downloadedChapters = currentState.downloadedChapters + chapter.id,
),
)
continue continue
} }
val pages = runFailsafe(pausingHandle) { val pages = runFailsafe(pausingHandle) {
@@ -222,11 +220,7 @@ class DownloadWorker @AssistedInject constructor(
localStorageChanges.emit(LocalMangaInput.of(output.rootFile).getManga()) localStorageChanges.emit(LocalMangaInput.of(output.rootFile).getManga())
}.onFailure(Throwable::printStackTraceDebug) }.onFailure(Throwable::printStackTraceDebug)
} }
publishState( publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1))
currentState.copy(
downloadedChapters = currentState.downloadedChapters + chapter.id,
),
)
} }
publishState(currentState.copy(isIndeterminate = true, eta = -1L)) publishState(currentState.copy(isIndeterminate = true, eta = -1L))
output.mergeWithExisting() output.mergeWithExisting()
@@ -333,11 +327,9 @@ class DownloadWorker @AssistedInject constructor(
setProgress(state.toWorkData()) setProgress(state.toWorkData())
} }
private suspend fun getDoneChapters(): LongArray { private suspend fun getDoneChapters(manga: Manga) = runCatchingCancellable {
val work = WorkManager.getInstance(applicationContext).awaitWorkInfoById(id) localMangaRepository.getDetails(manga).chapters?.ids()
?: return LongArray(0) }.getOrNull().orEmpty()
return DownloadState.getDownloadedChapters(work.progress)
}
private fun getChapters( private fun getChapters(
manga: Manga, manga: Manga,

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
@@ -18,6 +19,7 @@ import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import java.util.Collections import java.util.Collections
import java.util.EnumSet import java.util.EnumSet
import javax.inject.Inject import javax.inject.Inject
@@ -29,7 +31,7 @@ class MangaSourcesRepository @Inject constructor(
) { ) {
private val dao: MangaSourcesDao private val dao: MangaSourcesDao
get() = db.sourcesDao get() = db.getSourcesDao()
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply { private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
remove(MangaSource.LOCAL) remove(MangaSource.LOCAL)
@@ -42,15 +44,44 @@ class MangaSourcesRepository @Inject constructor(
get() = Collections.unmodifiableSet(remoteSources) get() = Collections.unmodifiableSet(remoteSources)
suspend fun getEnabledSources(): List<MangaSource> { suspend fun getEnabledSources(): List<MangaSource> {
return dao.findAllEnabled().toSources(settings.isNsfwContentDisabled) val order = settings.sourcesSortOrder
return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order)
} }
fun observeEnabledSources(): Flow<List<MangaSource>> = observeIsNsfwDisabled().flatMapLatest { skipNsfw -> suspend fun getDisabledSources(): List<MangaSource> {
dao.observeEnabled().map { return dao.findAllDisabled().toSources(settings.isNsfwContentDisabled, null)
it.toSources(skipNsfw)
}
} }
fun observeEnabledSourcesCount(): Flow<Int> {
return combine(
observeIsNsfwDisabled(),
dao.observeEnabled(SourcesSortOrder.MANUAL),
) { skipNsfw, sources ->
sources.count { skipNsfw || !MangaSource(it.source).isNsfw() }
}.distinctUntilChanged()
}
fun observeAvailableSourcesCount(): Flow<Int> {
return combine(
observeIsNsfwDisabled(),
dao.observeEnabled(SourcesSortOrder.MANUAL),
) { skipNsfw, enabledSources ->
val enabled = enabledSources.mapToSet { it.source }
allMangaSources.count { x ->
x.name !in enabled && (!skipNsfw || !x.isNsfw())
}
}.distinctUntilChanged()
}
fun observeEnabledSources(): Flow<List<MangaSource>> = combine(
observeIsNsfwDisabled(),
observeSortOrder(),
) { skipNsfw, order ->
dao.observeEnabled(order).map {
it.toSources(skipNsfw, order)
}
}.flatMapLatest { it }
fun observeAll(): Flow<List<Pair<MangaSource, Boolean>>> = dao.observeAll().map { entities -> fun observeAll(): Flow<List<Pair<MangaSource, Boolean>>> = dao.observeAll().map { entities ->
val result = ArrayList<Pair<MangaSource, Boolean>>(entities.size) val result = ArrayList<Pair<MangaSource, Boolean>>(entities.size)
for (entity in entities) { for (entity in entities) {
@@ -92,19 +123,25 @@ class MangaSourcesRepository @Inject constructor(
} }
} }
fun observeNewSources(): Flow<Set<MangaSource>> = combine( fun observeNewSources(): Flow<Set<MangaSource>> = observeIsNewSourcesEnabled().flatMapLatest {
dao.observeAll(), if (it) {
observeIsNsfwDisabled(), combine(
) { entities, skipNsfw -> dao.observeAll(),
val result = EnumSet.copyOf(remoteSources) observeIsNsfwDisabled(),
for (e in entities) { ) { entities, skipNsfw ->
result.remove(MangaSource(e.source)) 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())
} }
if (skipNsfw) { }
result.removeAll { x -> x.isNsfw() }
}
result
}.distinctUntilChanged()
suspend fun assimilateNewSources(): Set<MangaSource> { suspend fun assimilateNewSources(): Set<MangaSource> {
val new = getNewSources() val new = getNewSources()
@@ -139,7 +176,10 @@ class MangaSourcesRepository @Inject constructor(
return result return result
} }
private fun List<MangaSourceEntity>.toSources(skipNsfwSources: Boolean): List<MangaSource> { private fun List<MangaSourceEntity>.toSources(
skipNsfwSources: Boolean,
sortOrder: SourcesSortOrder?,
): List<MangaSource> {
val result = ArrayList<MangaSource>(size) val result = ArrayList<MangaSource>(size)
for (entity in this) { for (entity in this) {
val source = MangaSource(entity.source) val source = MangaSource(entity.source)
@@ -150,10 +190,21 @@ class MangaSourcesRepository @Inject constructor(
result.add(source) result.add(source)
} }
} }
if (sortOrder == SourcesSortOrder.ALPHABETIC) {
result.sortBy { it.title }
}
return result return result
} }
private fun observeIsNsfwDisabled() = settings.observeAsFlow(AppSettings.KEY_DISABLE_NSFW) { private fun observeIsNsfwDisabled() = settings.observeAsFlow(AppSettings.KEY_DISABLE_NSFW) {
isNsfwContentDisabled isNsfwContentDisabled
} }
private fun observeIsNewSourcesEnabled() = settings.observeAsFlow(AppSettings.KEY_SOURCES_NEW) {
isNewSourcesTipEnabled
}
private fun observeSortOrder() = settings.observeAsFlow(AppSettings.KEY_SOURCES_ORDER) {
sourcesSortOrder
}
} }

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.explore.data
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
enum class SourcesSortOrder(
@StringRes val titleResId: Int,
) {
ALPHABETIC(R.string.by_name),
POPULARITY(R.string.popular),
MANUAL(R.string.manual),
}

View File

@@ -9,6 +9,7 @@ import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist
@@ -73,7 +74,15 @@ class ExploreRepository @Inject constructor(
val tag = tags.firstNotNullOfOrNull { title -> val tag = tags.firstNotNullOfOrNull { title ->
availableTags.find { x -> x.title.almostEquals(title, 0.4f) } availableTags.find { x -> x.title.almostEquals(title, 0.4f) }
} }
val list = repository.getList(0, setOfNotNull(tag), order).asArrayList() val list = repository.getList(
offset = 0,
filter = MangaListFilter.Advanced(
sortOrder = order,
tags = setOfNotNull(tag),
locale = null,
states = emptySet(),
),
).asArrayList()
if (settings.isSuggestionsExcludeNsfw) { if (settings.isSuggestionsExcludeNsfw) {
list.removeAll { it.isNsfw } list.removeAll { it.isNsfw }
} }

View File

@@ -5,6 +5,7 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
@@ -18,7 +19,7 @@ class RecoverMangaUseCase @Inject constructor(
return@runCatchingCancellable null return@runCatchingCancellable null
} }
val repository = repositoryFactory.create(manga.source) val repository = repositoryFactory.create(manga.source)
val list = repository.getList(offset = 0, query = manga.title) val list = repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
val newManga = list.find { x -> x.title == manga.title }?.let { val newManga = list.find { x -> x.title == manga.title }?.let {
repository.getDetails(it) repository.getDetails(it)
} ?: return@runCatchingCancellable null } ?: return@runCatchingCancellable null

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.explore.ui package org.koitharu.kotatsu.explore.ui
import android.content.DialogInterface import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
@@ -46,6 +47,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
import javax.inject.Inject import javax.inject.Inject
@@ -83,7 +85,7 @@ class ExploreFragment :
SpanSizeResolver(this, resources.getDimensionPixelSize(R.dimen.explore_grid_width)).attach() SpanSizeResolver(this, resources.getDimensionPixelSize(R.dimen.explore_grid_width)).attach()
addItemDecoration(TypedListSpacingDecoration(context, false)) addItemDecoration(TypedListSpacingDecoration(context, false))
} }
addMenuProvider(ExploreMenuProvider(binding.root.context, viewModel)) addMenuProvider(ExploreMenuProvider(binding.root.context))
viewModel.content.observe(viewLifecycleOwner) { viewModel.content.observe(viewLifecycleOwner) {
exploreAdapter?.items = it exploreAdapter?.items = it
} }
@@ -109,7 +111,7 @@ class ExploreFragment :
} }
override fun onListHeaderClick(item: ListHeader, view: View) { override fun onListHeaderClick(item: ListHeader, view: View) {
startActivity(SettingsActivity.newManageSourcesIntent(view.context)) startActivity(Intent(view.context, SourcesCatalogActivity::class.java))
} }
override fun onPrimaryButtonClick(tipView: TipView) { override fun onPrimaryButtonClick(tipView: TipView) {
@@ -174,7 +176,6 @@ class ExploreFragment :
} else { } else {
LinearLayoutManager(requireContext()) LinearLayoutManager(requireContext())
} }
activity?.invalidateOptionsMenu()
} }
private fun showSuggestionsTip() { private fun showSuggestionsTip() {

View File

@@ -6,10 +6,10 @@ import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.settings.SettingsActivity
class ExploreMenuProvider( class ExploreMenuProvider(
private val context: Context, private val context: Context,
private val viewModel: ExploreViewModel,
) : MenuProvider { ) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
@@ -18,17 +18,12 @@ class ExploreMenuProvider(
override fun onMenuItemSelected(menuItem: MenuItem): Boolean { override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) { return when (menuItem.itemId) {
R.id.action_grid -> { R.id.action_manage -> {
viewModel.setGridMode(!menuItem.isChecked) context.startActivity(SettingsActivity.newSourcesSettingsIntent(context))
true true
} }
else -> false else -> false
} }
} }
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.action_grid)?.isChecked = viewModel.isGrid.value == true
}
} }

View File

@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.explore.ui.model.ExploreButtons import org.koitharu.kotatsu.explore.ui.model.ExploreButtons
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
@@ -50,11 +51,13 @@ class ExploreViewModel @Inject constructor(
valueProducer = { isSourcesGridMode }, valueProducer = { isSourcesGridMode },
) )
val isSuggestionsEnabled = settings.observeAsFlow( private val isSuggestionsEnabled = settings.observeAsFlow(
key = AppSettings.KEY_SUGGESTIONS, key = AppSettings.KEY_SUGGESTIONS,
valueProducer = { isSuggestionsEnabled }, valueProducer = { isSuggestionsEnabled },
) )
val sortOrder = MutableStateFlow(SourcesSortOrder.MANUAL) // TODO
val onOpenManga = MutableEventFlow<Manga>() val onOpenManga = MutableEventFlow<Manga>()
val onActionDone = MutableEventFlow<ReversibleAction>() val onActionDone = MutableEventFlow<ReversibleAction>()
val onShowSuggestionsTip = MutableEventFlow<Unit>() val onShowSuggestionsTip = MutableEventFlow<Unit>()
@@ -104,10 +107,6 @@ class ExploreViewModel @Inject constructor(
} }
} }
fun setGridMode(value: Boolean) {
settings.isSourcesGridMode = value
}
fun respondSuggestionTip(isAccepted: Boolean) { fun respondSuggestionTip(isAccepted: Boolean) {
settings.isSuggestionsEnabled = isAccepted settings.isSuggestionsEnabled = isAccepted
settings.closeTip(TIP_SUGGESTIONS) settings.closeTip(TIP_SUGGESTIONS)
@@ -137,7 +136,7 @@ class ExploreViewModel @Inject constructor(
result += RecommendationsItem(recommendation) result += RecommendationsItem(recommendation)
} }
if (sources.isNotEmpty()) { if (sources.isNotEmpty()) {
result += ListHeader(R.string.remote_sources, R.string.manage) result += ListHeader(R.string.remote_sources, R.string.catalog)
if (newSources.isNotEmpty()) { if (newSources.isNotEmpty()) {
result += TipModel( result += TipModel(
key = TIP_NEW_SOURCES, key = TIP_NEW_SOURCES,

View File

@@ -7,6 +7,7 @@ import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getSummary
import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.image.FaviconDrawable import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.ui.image.TrimTransformation
@@ -48,8 +49,8 @@ fun exploreButtonsAD(
icon.setColorSchemeColors( icon.setColorSchemeColors(
context.getThemeColor( context.getThemeColor(
materialR.attr.colorPrimary, materialR.attr.colorPrimary,
Color.DKGRAY Color.DKGRAY,
) ),
) )
binding.buttonRandom.icon = icon binding.buttonRandom.icon = icon
icon.start() icon.start()
@@ -98,7 +99,7 @@ fun exploreSourceListItemAD(
ItemExploreSourceListBinding.inflate( ItemExploreSourceListBinding.inflate(
layoutInflater, layoutInflater,
parent, parent,
false false,
) )
}, },
on = { item, _, _ -> item is MangaSourceItem && !item.isGrid }, on = { item, _, _ -> item is MangaSourceItem && !item.isGrid },
@@ -112,6 +113,7 @@ fun exploreSourceListItemAD(
bind { bind {
binding.textViewTitle.text = item.source.title binding.textViewTitle.text = item.source.title
binding.textViewSubtitle.text = item.source.getSummary(context)
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name) val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run { binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
fallback(fallbackIcon) fallback(fallbackIcon)
@@ -132,7 +134,7 @@ fun exploreSourceGridItemAD(
ItemExploreSourceGridBinding.inflate( ItemExploreSourceGridBinding.inflate(
layoutInflater, layoutInflater,
parent, parent,
false false,
) )
}, },
on = { item, _, _ -> item is MangaSourceItem && item.isGrid }, on = { item, _, _ -> item is MangaSourceItem && item.isGrid },

View File

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

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