Compare commits

...

142 Commits
v6.4 ... v6.6.3

Author SHA1 Message Date
Koitharu
54fb79dc98 Fix crash on start app update download 2024-01-12 18:18:04 +02:00
Koitharu
ea4c048029 Update parsers 2024-01-12 18:16:32 +02:00
Koitharu
badc826cd3 Hide search on unsupported sources 2024-01-12 18:15:00 +02:00
CakesTwix
f5ece8124e Translated using Weblate (Ukrainian)
Currently translated at 100.0% (554 of 554 strings)

Co-authored-by: CakesTwix <cakestwix1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2024-01-11 19:26:30 +02:00
Zero O
accdc41d6c Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (554 of 554 strings)

Co-authored-by: Zero O <godarms2010@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-01-11 19:26:30 +02:00
PacoteB2
819730984e Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (554 of 554 strings)

Co-authored-by: PacoteB2 <gop88662@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-01-11 19:26:30 +02:00
Anton Prevrhal
01c404f9e5 Translated using Weblate (German)
Currently translated at 88.6% (491 of 554 strings)

Co-authored-by: Anton Prevrhal <anton.prevrhal@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2024-01-11 19:26:30 +02:00
Anon
1fad686733 Translated using Weblate (Serbian)
Currently translated at 99.4% (551 of 554 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-01-11 19:26:30 +02:00
Сергій
396be6008d Translated using Weblate (Ukrainian)
Currently translated at 100.0% (554 of 554 strings)

Co-authored-by: Сергій <sergiy.goncharuk.1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2024-01-11 19:26:30 +02:00
ssantos
42f7846167 Translated using Weblate (Portuguese)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (554 of 554 strings)

Co-authored-by: ssantos <ssantos@web.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-01-11 19:26:30 +02:00
Макар Разин
dca56a43ee Translated using Weblate (Russian)
Currently translated at 100.0% (554 of 554 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (554 of 554 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-01-11 19:26:30 +02:00
Koitharu
cc91e56e1b Update parsers 2024-01-11 19:08:15 +02:00
Koitharu
627cf73d72 Action to mark manga as completed 2024-01-11 18:53:45 +02:00
Koitharu
514870f71c Filter exceptions reporting via ACRA 2024-01-07 15:51:16 +02:00
Koitharu
adffa800e8 Update parsers 2024-01-07 12:08:04 +02:00
Koitharu
3acca44b5e Update parsers 2024-01-06 15:33:49 +02:00
Koitharu
c7da4feb8f Fix details bottom sheet menu 2024-01-06 15:18:49 +02:00
Koitharu
baee9bee0e Update shikimori domain 2024-01-06 14:27:07 +02:00
Koitharu
ec41d36508 Fix converting bitmaps in local manga 2024-01-06 14:13:56 +02:00
Koitharu
8b63d227a7 Translated using Weblate (Russian)
Currently translated at 99.2% (550 of 554 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-01-05 16:48:58 +02:00
dont wana say
c9b48c8207 Translated using Weblate (Estonian)
Currently translated at 79.7% (442 of 554 strings)

Translated using Weblate (Estonian)

Currently translated at 85.7% (6 of 7 strings)

Co-authored-by: dont wana say <273ex2vl6@mozmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/et/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/et/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-01-05 16:48:58 +02:00
Haithem Dhiaeddine
6d7ce5205e Translated using Weblate (Arabic)
Currently translated at 51.4% (285 of 554 strings)

Co-authored-by: Haithem Dhiaeddine <haithemdjabi@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-01-05 16:48:58 +02:00
Ridhoardhiansyah7
5a02d534c9 Translated using Weblate (Indonesian)
Currently translated at 97.6% (540 of 553 strings)

Co-authored-by: Ridhoardhiansyah7 <Zxx97607@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-01-05 16:48:58 +02:00
gallegonovato
6128e5b699 Translated using Weblate (Spanish)
Currently translated at 100.0% (554 of 554 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (553 of 553 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-01-05 16:48:58 +02:00
InfinityDouki56
717a0ad4fb Translated using Weblate (Filipino)
Currently translated at 99.8% (553 of 554 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (548 of 548 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-01-05 16:48:58 +02:00
Anon
dee94ac0c4 Translated using Weblate (Serbian)
Currently translated at 100.0% (548 of 548 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-01-05 16:48:58 +02:00
Koitharu
9eec9a9957 Fix favorites backup #621 2024-01-05 16:40:48 +02:00
Koitharu
a4966b4661 Update parsers 2024-01-05 16:29:22 +02:00
Koitharu
58e570601d PageLoader improvements 2024-01-05 10:46:32 +02:00
Koitharu
7247cba855 Improve pages preview on details screen 2024-01-04 16:59:39 +02:00
Koitharu
d6012f9ddd Reset filter menu action 2024-01-04 11:05:09 +02:00
Koitharu
2eedd0b4a8 Fix filter chips 2024-01-04 10:37:06 +02:00
Koitharu
5e6da9bb1c Pages thumbnails on details screen 2024-01-03 19:47:35 +02:00
Koitharu
2f2a5b868d Excluded tags and content rating in filter 2024-01-02 20:18:44 +02:00
Isira Seneviratne
3f2e32dcc2 Revert to Java 8 2023-12-31 11:15:42 +02:00
Isira Seneviratne
004109a6bc Switch to java.time 2023-12-31 11:15:42 +02:00
Isira Seneviratne
6159ee36c4 Use TypedValueCompat 2023-12-31 11:15:29 +02:00
Koitharu
3b7d83dd6f Bump version 2023-12-31 10:59:30 +02:00
Koitharu
877a018ced Fix crashes 2023-12-31 10:28:46 +02:00
Koitharu
2e80b330e9 Fix tracker duplicates 2023-12-31 10:17:43 +02:00
Koitharu
42ca38e693 Update dependencies 2023-12-31 10:06:48 +02:00
Feroli
d2fc3354af Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: Feroli <feroli@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pt_BR/
Translation: Kotatsu/plurals
2023-12-31 10:03:25 +02:00
Koitharu
2a870e6167 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (548 of 548 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (548 of 548 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-12-31 10:03:25 +02:00
gallegonovato
393a9c2791 Translated using Weblate (Spanish)
Currently translated at 100.0% (548 of 548 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-12-31 10:03:25 +02:00
dont wana say
4c69839076 Translated using Weblate (Estonian)
Currently translated at 75.5% (414 of 548 strings)

Added translation using Weblate (Estonian)

Added translation using Weblate (Estonian)

Co-authored-by: dont wana say <273ex2vl6@mozmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/et/
Translation: Kotatsu/Strings
2023-12-31 10:03:25 +02:00
Koitharu
e37455e790 Update parsers and add support for Upcoming state 2023-12-28 17:48:00 +02:00
Paper Jack
36259ba901 Translated using Weblate (Italian)
Currently translated at 100.0% (546 of 546 strings)

Co-authored-by: Paper Jack <paperjack@tutanota.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2023-12-28 16:50:47 +02:00
Nicolò Bertazzo
5b041b9a49 Translated using Weblate (Italian)
Currently translated at 100.0% (546 of 546 strings)

Co-authored-by: Nicolò Bertazzo <n.bertazzo@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2023-12-28 16:50:47 +02:00
Koitharu
1734e888d6 Move new sources tip to catalog 2023-12-26 20:24:14 +02:00
Koitharu
9108646cea Update parsers 2023-12-25 19:52:37 +02:00
Макар Разин
c6d1cf2f72 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (546 of 546 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (546 of 546 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (546 of 546 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-12-25 13:26:51 +02:00
Anon
a317236cb0 Translated using Weblate (Serbian)
Currently translated at 100.0% (546 of 546 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2023-12-25 13:26:51 +02:00
Deivinni Silva
3703c07a98 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (546 of 546 strings)

Co-authored-by: Deivinni Silva <deivinnimds3656@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2023-12-25 13:26:51 +02:00
Koitharu
b7b32dd447 Update parsers 2023-12-25 13:18:28 +02:00
Koitharu
c103386dc5 Fix crash on file size compution 2023-12-25 10:01:43 +02:00
Koitharu
a9d6ee4a95 Update dependencies 2023-12-25 09:57:01 +02:00
Koitharu
71f2c91e5a Improve sources catalog 2023-12-23 08:44:09 +02:00
Koitharu
b878f358ff Improve explore screen 2023-12-23 07:26:31 +02:00
Deivinni Silva
4c9989da78 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (546 of 546 strings)

Co-authored-by: Deivinni Silva <deivinnimds3656@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2023-12-20 13:27:57 +02:00
ssantos
8e8424022a Translated using Weblate (Portuguese)
Currently translated at 100.0% (546 of 546 strings)

Co-authored-by: ssantos <ssantos@web.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2023-12-20 13:27:57 +02:00
Koitharu
86504b8bde Update parsers 2023-12-20 13:26:33 +02:00
Zakhar Timoshenko
f082fa084f Recreate splash screen 2023-12-19 21:36:23 +03:00
Zakhar Timoshenko
040fe258e9 Fix ActionMode popup coloring 2023-12-19 20:20:20 +03:00
Koitharu
1076009572 Temporary fix checking for new chapters on large collections #584 2023-12-19 17:37:23 +02:00
Koitharu
40dde71a1d Update parsers 2023-12-19 17:37:23 +02:00
Himawariin
9503aabf78 Translated using Weblate (Filipino)
Currently translated at 100.0% (546 of 546 strings)

Co-authored-by: Himawariin <milkytrackz@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-12-19 17:32:43 +02:00
Deivinni Silva
4ee16cfa2f Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (546 of 546 strings)

Co-authored-by: Deivinni Silva <deivinnimds3656@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-12-19 17:32:43 +02:00
Koitharu
f1c9eacaf0 Fix color correction apply buttons #597 2023-12-18 18:38:48 +02:00
Koitharu
920e16be10 Merge branch 'master' into devel 2023-12-16 08:46:02 +02:00
Koitharu
4e0e5be726 Update parsers
(cherry picked from commit 451a155e08)
2023-12-16 08:13:24 +02:00
Weblate (bot)
f18eca52af Translations update from Hosted Weblate (#594)
* Translated using Weblate (Thai)

Currently translated at 71.1% (387 of 544 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings

* Translated using Weblate (Spanish)

Currently translated at 100.0% (546 of 546 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings

* Translated using Weblate (Turkish)

Currently translated at 100.0% (546 of 546 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings

* Translated using Weblate (Russian)

Currently translated at 99.8% (545 of 546 strings)

Co-authored-by: Hotarun <ihotarun@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings

---------

Co-authored-by: Nayuki <me@nayuki.cyou>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: Hotarun <ihotarun@proton.me>
(cherry picked from commit 0612a7ad2c)
2023-12-16 08:13:17 +02:00
Koitharu
451a155e08 Update parsers 2023-12-16 08:12:42 +02:00
Weblate (bot)
0612a7ad2c Translations update from Hosted Weblate (#594)
* Translated using Weblate (Thai)

Currently translated at 71.1% (387 of 544 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings

* Translated using Weblate (Spanish)

Currently translated at 100.0% (546 of 546 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings

* Translated using Weblate (Turkish)

Currently translated at 100.0% (546 of 546 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings

* Translated using Weblate (Russian)

Currently translated at 99.8% (545 of 546 strings)

Co-authored-by: Hotarun <ihotarun@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings

---------

Co-authored-by: Nayuki <me@nayuki.cyou>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: Hotarun <ihotarun@proton.me>
2023-12-15 23:26:31 +03:00
Zakhar Timoshenko
9c34f25eda Fix navBar color in details screen 2023-12-15 23:02:21 +03:00
Zakhar Timoshenko
42360c678f Revert BottomNav and CTBLayout styles 2023-12-15 22:25:24 +03:00
HotarunIchijou
04a3d02aa9 Minority UI color changes (#593)
* Transparent navigation bar

* bump com.android.material to 1.11.0 and androidx.activity to 1.8.2

* bunp gradle to 8.2.0

* color changes for dynamic theme

* removed unused things
2023-12-15 22:14:16 +03:00
Koitharu
808fd13ad0 Fix saved chapters mapping 2023-12-14 15:16:15 +02:00
Koitharu
88c8dc4761 Fix crashes 2023-12-14 14:50:56 +02:00
Koitharu
a7eba67a97 Webtoon reader fixes 2023-12-14 14:36:03 +02:00
Koitharu
c27586231a Select which data will be restored from backup 2023-12-13 14:37:05 +02:00
Koitharu
db3db4637c Support manga sources in backups 2023-12-13 12:16:06 +02:00
Koitharu
bb2294f248 UI improvements 2023-12-13 11:33:55 +02:00
Koitharu
afd56c02e6 Update parsers 2023-12-12 19:13:29 +02:00
Koitharu
dcf1ffc976 Translated using Weblate (Russian)
Currently translated at 99.8% (543 of 544 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-12-12 19:13:17 +02:00
Nayuki
91b7028b1a Translated using Weblate (Thai)
Currently translated at 70.4% (382 of 542 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2023-12-12 19:13:17 +02:00
Anon
734c217c03 Translated using Weblate (Serbian)
Currently translated at 100.0% (542 of 542 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2023-12-12 19:13:17 +02:00
SENPAi-03
de18c6eb71 Translated using Weblate (Arabic)
Currently translated at 34.3% (186 of 542 strings)

Co-authored-by: SENPAi-03 <naemamro1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2023-12-12 19:13:17 +02:00
Oğuz Ersen
034be6b44e Translated using Weblate (Turkish)
Currently translated at 100.0% (544 of 544 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (542 of 542 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-12-12 19:13:17 +02:00
gallegonovato
995ff5a764 Translated using Weblate (Spanish)
Currently translated at 100.0% (542 of 542 strings)

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

Translated using Weblate (Russian)

Currently translated at 100.0% (542 of 542 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (542 of 542 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-12-12 19:13:17 +02:00
Zakhar Timoshenko
d05d807614 Slightly improve welcome screen 2023-12-12 20:05:59 +03:00
Koitharu
bffd75f4d9 Fix crashes 2023-12-12 18:55:22 +02:00
Koitharu
bdaf3da7e0 New welcome screen 2023-12-12 15:58:45 +02:00
Koitharu
353d856bf5 Fix chips style 2023-12-12 14:18:49 +02:00
Zakhar Timoshenko
fca9ba98cd Update parsers 2023-12-11 20:02:09 +03:00
Zakhar Timoshenko
5df76fd881 Fix crashes 2023-12-11 18:46:35 +03:00
Koitharu
54c646ceb0 Update parsers 2023-12-09 13:54:58 +02:00
Koitharu
3599f2f1b8 Improve onboarding 2023-12-09 13:46:39 +02:00
Koitharu
b2e53d4938 Fix some warnings and remove unused code 2023-12-09 13:31:43 +02:00
Koitharu
0d62408918 Update acra configuration 2023-12-09 11:25:14 +02:00
Koitharu
2ae046d4c5 Update acra configuration 2023-12-09 09:04:13 +02:00
Koitharu
66356dc094 Add doze disable preference for downloads 2023-12-08 12:18:18 +02:00
Koitharu
ae16110a80 Fix LocaleComparator 2023-12-08 08:47:56 +02:00
Zakhar Timoshenko
c3aff60a9c Spinner and EditText enhancements 2023-12-08 00:39:13 +03:00
gallegonovato
cfdca3434b Translated using Weblate (Spanish)
Currently translated at 100.0% (541 of 541 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (540 of 540 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (538 of 538 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (532 of 532 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-12-07 18:16:16 +02:00
Anon
b2c2693aba Translated using Weblate (Serbian)
Currently translated at 96.4% (519 of 538 strings)

Translated using Weblate (Serbian)

Currently translated at 97.5% (518 of 531 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/sr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-12-07 18:16:16 +02:00
Hasanur Rahman Biplob
5901c26ae0 Translated using Weblate (Bengali)
Currently translated at 30.6% (163 of 531 strings)

Co-authored-by: Hasanur Rahman Biplob <hrbiplob100@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/bn/
Translation: Kotatsu/Strings
2023-12-07 18:16:16 +02:00
ECBaris
d864c73faf Translated using Weblate (Turkish)
Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: ECBaris <barisaklan@outlook.com.tr>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/tr/
Translation: Kotatsu/plurals
2023-12-07 18:16:16 +02:00
Feroli
30551a56b2 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (531 of 531 strings)

Co-authored-by: Feroli <feroli@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2023-12-07 18:16:16 +02:00
La prière
23cb023a85 Translated using Weblate (Japanese)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Japanese)

Currently translated at 90.7% (482 of 531 strings)

Co-authored-by: La prière <lapriere@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ja/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-12-07 18:16:16 +02:00
Макар Разин
10291d5b29 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (541 of 541 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (541 of 541 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (541 of 541 strings)

Translated using Weblate (Polish)

Currently translated at 90.3% (480 of 531 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (531 of 531 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/pl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-12-07 18:16:16 +02:00
InfinityDouki56
e05e09f846 Translated using Weblate (Filipino)
Currently translated at 88.1% (477 of 541 strings)

Translated using Weblate (Filipino)

Currently translated at 88.5% (470 of 531 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-12-07 18:16:16 +02:00
Koitharu
0ce7f7cf6b Missing local manga cleanup #587 2023-12-07 18:11:30 +02:00
Koitharu
4d9d15004c Reader fixes 2023-12-07 16:38:33 +02:00
Koitharu
1908ce3e46 New filter implementation 2023-12-06 17:31:57 +02:00
Koitharu
6c07abec56 New filter sheet draft implementation 2023-12-05 17:14:24 +02:00
Koitharu
64dc646fc5 Language filter support 2023-12-05 15:15:51 +02:00
Koitharu
357669d8b2 Fix list items reorder 2023-12-05 09:59:07 +02:00
Koitharu
21639ddcbc Fix directories manage screen 2023-12-04 16:40:57 +02:00
Koitharu
5183d5e882 Grayscale color filter 2023-12-04 16:21:02 +02:00
Koitharu
3008b7b89a Fix downloader skip action 2023-12-04 14:05:22 +02:00
Koitharu
53e00e4689 Global color filter initial implementation #562 2023-12-02 15:45:49 +02:00
Koitharu
963d7d8d42 Fix concurrent manga downloading 2023-12-02 15:43:47 +02:00
Koitharu
1a7b1e7bdc Ability to skip error in downloader 2023-12-02 15:03:15 +02:00
Koitharu
b1fa9d1d22 Fix mapping local chapters #577 2023-12-02 15:02:48 +02:00
Koitharu
91179ef901 Show chapters in downloads list 2023-12-02 14:18:39 +02:00
Koitharu
a7a9ee9d59 Downloader improvements 2023-12-02 09:11:33 +02:00
Koitharu
ff05f3f79d Upgrade WorkManager 2023-11-30 12:53:44 +02:00
Koitharu
c0062c83c8 Upgrade room 2023-11-30 11:52:17 +02:00
Koitharu
ef0cf4766a Fix webtoon mode 2023-11-29 18:07:53 +02:00
Koitharu
910069ec99 Update parsers 2023-11-29 17:25:13 +02:00
Koitharu
d56107bf1f Remove funding info 2023-11-29 09:53:23 +02:00
Koitharu
03426694c8 Bump version 2023-11-29 09:26:45 +02:00
Koitharu
385003bcc8 Fix filter chips #572 2023-11-28 16:11:30 +02:00
Koitharu
225aacff43 Temporary disable downsampling in webtoon mode 2023-11-28 15:57:18 +02:00
Макар Разин
208c0a494b Translated using Weblate (Ukrainian)
Currently translated at 100.0% (531 of 531 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (531 of 531 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (531 of 531 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-28 15:56:16 +02:00
Bai
0045c7cf44 Translated using Weblate (Turkish)
Currently translated at 100.0% (531 of 531 strings)

Co-authored-by: Bai <batuhanakkurt000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-11-28 15:56:16 +02:00
J. Lavoie
eed7f89518 Translated using Weblate (French)
Currently translated at 99.8% (530 of 531 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-11-28 15:56:16 +02:00
gallegonovato
80c8b9eac0 Translated using Weblate (Spanish)
Currently translated at 100.0% (531 of 531 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-11-28 15:56:16 +02:00
ECBaris
53a680d13c Translated using Weblate (Turkish)
Currently translated at 98.6% (520 of 527 strings)

Co-authored-by: ECBaris <barisaklan@outlook.com.tr>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-11-28 15:56:16 +02:00
Koitharu
3e77df20a2 Improve manga memory cache usage 2023-11-26 09:07:56 +02:00
Zakhar Timoshenko
7c1c0a38fa Update parsers lib 2023-11-25 19:53:07 +03:00
308 changed files with 7258 additions and 3195 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +0,0 @@
ko_fi: xtimms
custom: ["https://yoomoney.ru/to/410012543938752"]

2
.gitignore vendored
View File

@@ -10,11 +10,13 @@
/.idea/compiler.xml /.idea/compiler.xml
/.idea/workspace.xml /.idea/workspace.xml
/.idea/navEditor.xml /.idea/navEditor.xml
/.idea/ktlint-plugin.xml
/.idea/assetWizardSettings.xml /.idea/assetWizardSettings.xml
/.idea/kotlinScripting.xml /.idea/kotlinScripting.xml
/.idea/kotlinc.xml /.idea/kotlinc.xml
/.idea/deploymentTargetDropDown.xml /.idea/deploymentTargetDropDown.xml
/.idea/androidTestResultsUserPreferences.xml /.idea/androidTestResultsUserPreferences.xml
/.idea/deploymentTargetSelector.xml
/.idea/render.experimental.xml /.idea/render.experimental.xml
/.idea/inspectionProfiles/ /.idea/inspectionProfiles/
.DS_Store .DS_Store

View File

@@ -16,13 +16,13 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 600 versionCode = 614
versionName = '6.4' versionName = '6.6.3'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner" testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {
// arg("room.generateKotlin", "true") TODO: enable later arg('room.generateKotlin', 'true')
arg("room.schemaLocation", "$projectDir/schemas") arg('room.schemaLocation', "$projectDir/schemas")
} }
androidResources { androidResources {
generateLocaleConfig true generateLocaleConfig true
@@ -82,20 +82,19 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:46e863ef79') { implementation('com.github.KotatsuApp:kotatsu-parsers:a2979753a9') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.20' implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22'
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.1' implementation 'androidx.activity:activity-ktx:1.8.2'
implementation 'androidx.fragment:fragment-ktx:1.6.2' 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-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'
@@ -104,11 +103,10 @@ dependencies {
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.10.0' implementation 'com.google.android.material:material:1.11.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 implementation 'androidx.work:work-runtime:2.9.0'
implementation 'androidx.work:work-runtime-ktx:2.8.1'
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.google.guava:guava:32.0.1-android') { implementation('com.google.guava:guava:32.0.1-android') {
exclude group: 'com.google.guava', module: 'failureaccess' exclude group: 'com.google.guava', module: 'failureaccess'
@@ -116,32 +114,34 @@ dependencies {
exclude group: 'com.google.j2objc', module: 'j2objc-annotations' exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
} }
implementation 'androidx.room:room-runtime:2.6.0' implementation 'androidx.room:room-runtime:2.6.1'
implementation 'androidx.room:room-ktx:2.6.0' implementation 'androidx.room:room-ktx:2.6.1'
ksp 'androidx.room:room-compiler:2.6.0' ksp 'androidx.room:room-compiler:2.6.1'
implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
implementation 'com.squareup.okio:okio:3.6.0' implementation 'com.squareup.okio:okio:3.7.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.1' implementation 'com.google.dagger:hilt-android:2.50'
kapt 'com.google.dagger:hilt-compiler:2.48.1' kapt 'com.google.dagger:hilt-compiler:2.50'
implementation 'androidx.hilt:hilt-work:1.1.0' implementation 'androidx.hilt:hilt-work:1.1.0'
kapt 'androidx.hilt:hilt-compiler:1.1.0' kapt 'androidx.hilt:hilt-compiler:1.1.0'
implementation 'io.coil-kt:coil-base:2.5.0' implementation 'io.coil-kt:coil-base:2.5.0'
implementation 'io.coil-kt:coil-svg:2.5.0' implementation 'io.coil-kt:coil-svg:2.5.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:c7dab3aefe' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9'
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.3' implementation 'ch.acra:acra-http:5.11.3'
implementation 'ch.acra:acra-dialog:5.11.3' implementation 'ch.acra:acra-dialog:5.11.3'
compileOnly 'com.google.auto.service:auto-service-annotations:1.1.1'
ksp 'dev.zacsweers.autoservice:auto-service-ksp:1.1.0'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.13'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20231013' testImplementation 'org.json:json:20231013'
@@ -154,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.6.0' androidTestImplementation 'androidx.room:room-testing:2.6.1'
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.1' androidTestImplementation 'com.google.dagger:hilt-android-testing:2.50'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.48.1' kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.50'
} }

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.bookmarks.data
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date import java.time.Instant
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark( fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
manga = manga, manga = manga,
@@ -11,7 +11,7 @@ fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
page = page, page = page,
scroll = scroll, scroll = scroll,
imageUrl = imageUrl, imageUrl = imageUrl,
createdAt = Date(createdAt), createdAt = Instant.ofEpochMilli(createdAt),
percent = percent, percent = percent,
) )
@@ -22,7 +22,7 @@ fun Bookmark.toEntity() = BookmarkEntity(
page = page, page = page,
scroll = scroll, scroll = scroll,
imageUrl = imageUrl, imageUrl = imageUrl,
createdAt = createdAt.time, createdAt = createdAt.toEpochMilli(),
percent = percent, percent = percent,
) )

View File

@@ -4,7 +4,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.local.data.hasImageExtension 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.time.Instant
data class Bookmark( data class Bookmark(
val manga: Manga, val manga: Manga,
@@ -13,7 +13,7 @@ data class Bookmark(
val page: Int, val page: Int,
val scroll: Int, val scroll: Int,
val imageUrl: String, val imageUrl: String,
val createdAt: Date, val createdAt: Instant,
val percent: Float, val percent: Float,
) : ListModel { ) : ListModel {

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
class CaptchaNotifier( class CaptchaNotifier(
private val context: Context, private val context: Context,
@@ -58,16 +59,27 @@ class CaptchaNotifier(
manager.notify(TAG, exception.source.hashCode(), notification) manager.notify(TAG, exception.source.hashCode(), notification)
} }
fun dismiss(source: MangaSource) {
NotificationManagerCompat.from(context).cancel(TAG, source.hashCode())
}
override fun onError(request: ImageRequest, result: ErrorResult) { override fun onError(request: ImageRequest, result: ErrorResult) {
super.onError(request, result) super.onError(request, result)
val e = result.throwable val e = result.throwable
if (e is CloudFlareProtectedException) { if (e is CloudFlareProtectedException && request.parameters.value<Boolean>(PARAM_IGNORE_CAPTCHA) != true) {
notify(e) notify(e)
} }
} }
private companion object { companion object {
fun ImageRequest.Builder.ignoreCaptchaErrors() = setParameter(
key = PARAM_IGNORE_CAPTCHA,
value = true,
memoryCacheKey = null,
)
private const val PARAM_IGNORE_CAPTCHA = "ignore_captcha"
private const val CHANNEL_ID = "captcha" private const val CHANNEL_ID = "captcha"
private const val TAG = CHANNEL_ID private const val TAG = CHANNEL_ID
} }

View File

@@ -11,6 +11,7 @@ import androidx.work.WorkManager
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.acra.ACRA import org.acra.ACRA
import org.acra.ReportField import org.acra.ReportField
import org.acra.config.dialog import org.acra.config.dialog
@@ -39,7 +40,7 @@ open class BaseApp : Application(), Configuration.Provider {
lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks> lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks>
@Inject @Inject
lateinit var database: MangaDatabase lateinit var database: Provider<MangaDatabase>
@Inject @Inject
lateinit var settings: AppSettings lateinit var settings: AppSettings
@@ -51,21 +52,31 @@ open class BaseApp : Application(), Configuration.Provider {
lateinit var appValidator: AppValidator lateinit var appValidator: AppValidator
@Inject @Inject
lateinit var workScheduleManager: WorkScheduleManager lateinit var workScheduleManager: Provider<WorkScheduleManager>
@Inject @Inject
lateinit var workManagerProvider: Provider<WorkManager> lateinit var workManagerProvider: Provider<WorkManager>
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.toString())
AppCompatDelegate.setDefaultNightMode(settings.theme) AppCompatDelegate.setDefaultNightMode(settings.theme)
AppCompatDelegate.setApplicationLocales(settings.appLocales) AppCompatDelegate.setApplicationLocales(settings.appLocales)
setupActivityLifecycleCallbacks() setupActivityLifecycleCallbacks()
processLifecycleScope.launch {
val isOriginalApp = withContext(Dispatchers.Default) {
appValidator.isOriginalApp
}
ACRA.errorReporter.putCustomData("isOriginalApp", isOriginalApp.toString())
}
processLifecycleScope.launch(Dispatchers.Default) { processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers() setupDatabaseObservers()
} }
workScheduleManager.init() workScheduleManager.get().init()
WorkServiceStopHelper(workManagerProvider).setup() WorkServiceStopHelper(workManagerProvider).setup()
} }
@@ -74,13 +85,6 @@ open class BaseApp : Application(), Configuration.Provider {
initAcra { initAcra {
buildConfigClass = BuildConfig::class.java buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON reportFormat = StringFormat.JSON
excludeMatchingSharedPreferencesKeys = listOf(
"sources_\\w+",
AppSettings.KEY_APP_PASSWORD,
AppSettings.KEY_PROXY_LOGIN,
AppSettings.KEY_PROXY_ADDRESS,
AppSettings.KEY_PROXY_PASSWORD,
)
httpSender { httpSender {
uri = getString(R.string.url_error_report) uri = getString(R.string.url_error_report)
basicAuthLogin = getString(R.string.acra_login) basicAuthLogin = getString(R.string.acra_login)
@@ -97,7 +101,6 @@ open class BaseApp : Application(), Configuration.Provider {
ReportField.STACK_TRACE, ReportField.STACK_TRACE,
ReportField.CRASH_CONFIGURATION, ReportField.CRASH_CONFIGURATION,
ReportField.CUSTOM_DATA, ReportField.CUSTOM_DATA,
ReportField.SHARED_PREFERENCES,
) )
dialog { dialog {
@@ -110,15 +113,9 @@ open class BaseApp : Application(), Configuration.Provider {
} }
} }
override fun getWorkManagerConfiguration(): Configuration {
return Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
}
@WorkerThread @WorkerThread
private fun setupDatabaseObservers() { private fun setupDatabaseObservers() {
val tracker = database.invalidationTracker val tracker = database.get().invalidationTracker
databaseObservers.forEach { databaseObservers.forEach {
tracker.addObserver(it) tracker.addObserver(it)
} }

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.core
import android.content.Context
import com.google.auto.service.AutoService
import org.acra.builder.ReportBuilder
import org.acra.config.CoreConfiguration
import org.acra.config.ReportingAdministrator
@AutoService(ReportingAdministrator::class)
class ErrorReportingAdmin : ReportingAdministrator {
override fun shouldStartCollecting(
context: Context,
config: CoreConfiguration,
reportBuilder: ReportBuilder
): Boolean {
return reportBuilder.exception?.isDeadOs() != true
}
private fun Throwable.isDeadOs(): Boolean {
val className = javaClass.simpleName
return className == "DeadSystemException" || className == "DeadSystemRuntimeException" || cause?.isDeadOs() == true
}
}

View File

@@ -3,17 +3,20 @@ package org.koitharu.kotatsu.core.backup
import org.json.JSONArray import org.json.JSONArray
class BackupEntry( class BackupEntry(
val name: String, val name: Name,
val data: JSONArray val data: JSONArray
) { ) {
companion object Names { enum class Name(
val key: String,
) {
const val INDEX = "index" INDEX("index"),
const val HISTORY = "history" HISTORY("history"),
const val CATEGORIES = "categories" CATEGORIES("categories"),
const val FAVOURITES = "favourites" FAVOURITES("favourites"),
const val SETTINGS = "settings" SETTINGS("settings"),
const val BOOKMARKS = "bookmarks" BOOKMARKS("bookmarks"),
SOURCES("sources"),
} }
} }

View File

@@ -7,8 +7,10 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.json.JSONIterator import org.koitharu.kotatsu.parsers.util.json.JSONIterator
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Date
import javax.inject.Inject import javax.inject.Inject
private const val PAGE_SIZE = 10 private const val PAGE_SIZE = 10
@@ -20,7 +22,7 @@ class BackupRepository @Inject constructor(
suspend fun dumpHistory(): BackupEntry { suspend fun dumpHistory(): BackupEntry {
var offset = 0 var offset = 0
val entry = BackupEntry(BackupEntry.HISTORY, JSONArray()) val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray())
while (true) { while (true) {
val history = db.getHistoryDao().findAll(offset, PAGE_SIZE) val history = db.getHistoryDao().findAll(offset, PAGE_SIZE)
if (history.isEmpty()) { if (history.isEmpty()) {
@@ -41,7 +43,7 @@ class BackupRepository @Inject constructor(
} }
suspend fun dumpCategories(): BackupEntry { suspend fun dumpCategories(): BackupEntry {
val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray()) val entry = BackupEntry(BackupEntry.Name.CATEGORIES, JSONArray())
val categories = db.getFavouriteCategoriesDao().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())
@@ -51,9 +53,9 @@ class BackupRepository @Inject constructor(
suspend fun dumpFavourites(): BackupEntry { suspend fun dumpFavourites(): BackupEntry {
var offset = 0 var offset = 0
val entry = BackupEntry(BackupEntry.FAVOURITES, JSONArray()) val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray())
while (true) { while (true) {
val favourites = db.getFavouritesDao().findAll(offset, PAGE_SIZE) val favourites = db.getFavouritesDao().findAllRaw(offset, PAGE_SIZE)
if (favourites.isEmpty()) { if (favourites.isEmpty()) {
break break
} }
@@ -72,7 +74,7 @@ class BackupRepository @Inject constructor(
} }
suspend fun dumpBookmarks(): BackupEntry { suspend fun dumpBookmarks(): BackupEntry {
val entry = BackupEntry(BackupEntry.BOOKMARKS, JSONArray()) val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray())
val all = db.getBookmarksDao().findAll() val all = db.getBookmarksDao().findAll()
for ((m, b) in all) { for ((m, b) in all) {
val json = JSONObject() val json = JSONObject()
@@ -90,7 +92,7 @@ class BackupRepository @Inject constructor(
} }
fun dumpSettings(): BackupEntry { fun dumpSettings(): BackupEntry {
val entry = BackupEntry(BackupEntry.SETTINGS, JSONArray()) val entry = BackupEntry(BackupEntry.Name.SETTINGS, JSONArray())
val settingsDump = settings.getAllValues().toMutableMap() val settingsDump = settings.getAllValues().toMutableMap()
settingsDump.remove(AppSettings.KEY_APP_PASSWORD) settingsDump.remove(AppSettings.KEY_APP_PASSWORD)
settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD) settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD)
@@ -101,8 +103,18 @@ class BackupRepository @Inject constructor(
return entry return entry
} }
suspend fun dumpSources(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
val all = db.getSourcesDao().findAll()
for (source in all) {
val json = JsonSerializer(source).toJson()
entry.data.put(json)
}
return entry
}
fun createIndex(): BackupEntry { fun createIndex(): BackupEntry {
val entry = BackupEntry(BackupEntry.INDEX, JSONArray()) val entry = BackupEntry(BackupEntry.Name.INDEX, JSONArray())
val json = JSONObject() val json = JSONObject()
json.put("app_id", BuildConfig.APPLICATION_ID) json.put("app_id", BuildConfig.APPLICATION_ID)
json.put("app_version", BuildConfig.VERSION_CODE) json.put("app_version", BuildConfig.VERSION_CODE)
@@ -111,6 +123,11 @@ class BackupRepository @Inject constructor(
return entry return entry
} }
fun getBackupDate(entry: BackupEntry?): Date? {
val timestamp = entry?.data?.optJSONObject(0)?.getLongOrDefault("created_at", 0) ?: 0
return if (timestamp == 0L) null else Date(timestamp)
}
suspend fun restoreHistory(entry: BackupEntry): CompositeResult { suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
val result = CompositeResult() val result = CompositeResult()
for (item in entry.data.JSONIterator()) { for (item in entry.data.JSONIterator()) {
@@ -184,6 +201,17 @@ class BackupRepository @Inject constructor(
return result return result
} }
suspend fun restoreSources(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
val source = JsonDeserializer(item).toMangaSourceEntity()
result += runCatchingCancellable {
db.getSourcesDao().upsert(source)
}
}
return result
}
fun restoreSettings(entry: BackupEntry): CompositeResult { fun restoreSettings(entry: BackupEntry): CompositeResult {
val result = CompositeResult() val result = CompositeResult()
for (item in entry.data.JSONIterator()) { for (item in entry.data.JSONIterator()) {

View File

@@ -1,25 +1,44 @@
package org.koitharu.kotatsu.core.backup package org.koitharu.kotatsu.core.backup
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okio.Closeable import okio.Closeable
import org.json.JSONArray import org.json.JSONArray
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import java.io.File import java.io.File
import java.util.EnumSet
import java.util.zip.ZipFile import java.util.zip.ZipFile
class BackupZipInput(val file: File) : Closeable { class BackupZipInput(val file: File) : Closeable {
private val zipFile = ZipFile(file) private val zipFile = ZipFile(file)
suspend fun getEntry(name: String): BackupEntry? = runInterruptible(Dispatchers.IO) { suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) {
val entry = zipFile.getEntry(name) ?: return@runInterruptible null val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null
val json = zipFile.getInputStream(entry).use { val json = zipFile.getInputStream(entry).use {
JSONArray(it.bufferedReader().readText()) JSONArray(it.bufferedReader().readText())
} }
BackupEntry(name, json) BackupEntry(name, json)
} }
suspend fun entries(): Set<BackupEntry.Name> = runInterruptible(Dispatchers.IO) {
zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze ->
BackupEntry.Name.entries.find { it.key == ze.name }
}
}
override fun close() { override fun close() {
zipFile.close() zipFile.close()
} }
fun cleanupAsync() {
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
runCatching {
close()
file.delete()
}
}
}
} }

View File

@@ -5,10 +5,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okio.Closeable import okio.Closeable
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.format
import org.koitharu.kotatsu.core.zip.ZipOutput import org.koitharu.kotatsu.core.zip.ZipOutput
import java.io.File import java.io.File
import java.util.Date import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
import java.util.zip.Deflater import java.util.zip.Deflater
@@ -17,7 +17,7 @@ class BackupZipOutput(val file: File) : Closeable {
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION) private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) { suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
output.put(entry.name, entry.data.toString(2)) output.put(entry.name.key, entry.data.toString(2))
} }
suspend fun finish() = runInterruptible(Dispatchers.IO) { suspend fun finish() = runInterruptible(Dispatchers.IO) {
@@ -39,7 +39,7 @@ suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptibl
val filename = buildString { val filename = buildString {
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT)) append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
append('_') append('_')
append(Date().format("ddMMyyyy")) append(LocalDate.now().format(DateTimeFormatter.ofPattern("ddMMyyyy")))
append(".bk.zip") append(".bk.zip")
} }
BackupZipOutput(File(dir, filename)) BackupZipOutput(File(dir, filename))

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.backup
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
@@ -78,6 +79,12 @@ class JsonDeserializer(private val json: JSONObject) {
percent = json.getDouble("percent").toFloat(), percent = json.getDouble("percent").toFloat(),
) )
fun toMangaSourceEntity() = MangaSourceEntity(
source = json.getString("source"),
isEnabled = json.getBoolean("enabled"),
sortKey = json.getInt("sort_key"),
)
fun toMap(): Map<String, Any?> { fun toMap(): Map<String, Any?> {
val map = mutableMapOf<String, Any?>() val map = mutableMapOf<String, Any?>()
val keys = json.keys() val keys = json.keys()

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.backup
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
@@ -82,6 +83,14 @@ class JsonSerializer private constructor(private val json: JSONObject) {
}, },
) )
constructor(e: MangaSourceEntity) : this(
JSONObject().apply {
put("source", e.source)
put("enabled", e.isEnabled)
put("sort_key", e.sortKey)
},
)
constructor(m: Map<String, *>) : this( constructor(m: Map<String, *>) : this(
JSONObject(m), JSONObject(m),
) )

View File

@@ -12,7 +12,7 @@ class ExpiringLruCache<T>(
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize) private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
operator fun get(key: ContentCache.Key): T? { operator fun get(key: ContentCache.Key): T? {
val value = cache.get(key) ?: return null val value = cache[key] ?: return null
if (value.isExpired) { if (value.isExpired) {
cache.remove(key) cache.remove(key)
} }

View File

@@ -29,6 +29,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration13To14
import org.koitharu.kotatsu.core.db.migrations.Migration14To15 import org.koitharu.kotatsu.core.db.migrations.Migration14To15
import org.koitharu.kotatsu.core.db.migrations.Migration15To16 import org.koitharu.kotatsu.core.db.migrations.Migration15To16
import org.koitharu.kotatsu.core.db.migrations.Migration16To17 import org.koitharu.kotatsu.core.db.migrations.Migration16To17
import org.koitharu.kotatsu.core.db.migrations.Migration17To18
import org.koitharu.kotatsu.core.db.migrations.Migration1To2 import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration2To3 import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4 import org.koitharu.kotatsu.core.db.migrations.Migration3To4
@@ -53,7 +54,7 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 17 const val DATABASE_VERSION = 18
@Database( @Database(
entities = [ entities = [
@@ -108,6 +109,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration14To15(), Migration14To15(),
Migration15To16(), Migration15To16(),
Migration16To17(context), Migration16To17(context),
Migration17To18(),
) )
fun MangaDatabase(context: Context): MangaDatabase = Room fun MangaDatabase(context: Context): MangaDatabase = Room

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.db.dao package org.koitharu.kotatsu.core.db.dao
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
@@ -23,6 +24,10 @@ abstract class MangaDao {
@Query("SELECT * FROM manga WHERE public_url = :publicUrl") @Query("SELECT * FROM manga WHERE public_url = :publicUrl")
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags? abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
@Transaction
@Query("SELECT * FROM manga WHERE source = :source")
abstract suspend fun findAllBySource(source: String): List<MangaWithTags>
@Transaction @Transaction
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit") @Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags> abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
@@ -43,6 +48,10 @@ abstract class MangaDao {
@Query("DELETE FROM manga_tags WHERE manga_id = :mangaId") @Query("DELETE FROM manga_tags WHERE manga_id = :mangaId")
abstract suspend fun clearTagRelation(mangaId: Long) abstract suspend fun clearTagRelation(mangaId: Long)
@Transaction
@Delete
abstract suspend fun delete(subjects: Collection<MangaEntity>)
@Transaction @Transaction
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) { open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
upsert(manga) upsert(manga)

View File

@@ -31,6 +31,16 @@ abstract class TagsDao {
) )
abstract suspend fun findPopularTags(source: String, limit: Int): List<TagEntity> abstract suspend fun findPopularTags(source: String, limit: Int): List<TagEntity>
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE tags.source = :source
GROUP BY tags.title
ORDER BY COUNT(manga_id) ASC
LIMIT :limit""",
)
abstract suspend fun findRareTags(source: String, limit: Int): List<TagEntity>
@Query( @Query(
"""SELECT tags.* FROM tags """SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id

View File

@@ -24,4 +24,5 @@ data class MangaPrefsEntity(
@ColumnInfo(name = "cf_brightness") val cfBrightness: Float, @ColumnInfo(name = "cf_brightness") val cfBrightness: Float,
@ColumnInfo(name = "cf_contrast") val cfContrast: Float, @ColumnInfo(name = "cf_contrast") val cfContrast: Float,
@ColumnInfo(name = "cf_invert") val cfInvert: Boolean, @ColumnInfo(name = "cf_invert") val cfInvert: Boolean,
@ColumnInfo(name = "cf_grayscale") val cfGrayscale: Boolean,
) )

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration17To18 : Migration(17, 18) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_grayscale` INTEGER NOT NULL DEFAULT 0")
}
}

View File

@@ -1,8 +0,0 @@
package org.koitharu.kotatsu.core.exceptions
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
class CompositeException(val errors: Collection<Throwable>) : Exception() {
override val message: String = errors.mapNotNullToSet { it.message }.joinToString()
}

View File

@@ -1,13 +1,13 @@
package org.koitharu.kotatsu.core.exceptions package org.koitharu.kotatsu.core.exceptions
import okio.IOException import okio.IOException
import java.util.Date import java.time.Instant
import java.time.temporal.ChronoUnit
class TooManyRequestExceptions( class TooManyRequestExceptions(
val url: String, val url: String,
val retryAt: Date?, val retryAt: Instant?,
) : IOException() { ) : IOException() {
val retryAfter: Long val retryAfter: Long
get() = if (retryAt == null) 0 else (retryAt.time - System.currentTimeMillis()).coerceAtLeast(0) get() = retryAt?.until(Instant.now(), ChronoUnit.MILLIS) ?: 0
} }

View File

@@ -20,8 +20,9 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.text.SimpleDateFormat import java.time.LocalDateTime
import java.util.Date import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale import java.util.Locale
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
@@ -41,11 +42,7 @@ class FileLogger(
} }
val isEnabled: Boolean val isEnabled: Boolean
get() = settings.isLoggingEnabled get() = settings.isLoggingEnabled
private val dateFormat = SimpleDateFormat.getDateTimeInstance( private val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withLocale(Locale.ROOT)
SimpleDateFormat.SHORT,
SimpleDateFormat.SHORT,
Locale.ROOT,
)
private val buffer = ConcurrentLinkedQueue<String>() private val buffer = ConcurrentLinkedQueue<String>()
private val mutex = Mutex() private val mutex = Mutex()
private var flushJob: Job? = null private var flushJob: Job? = null
@@ -55,7 +52,7 @@ class FileLogger(
return return
} }
val text = buildString { val text = buildString {
append(dateFormat.format(Date())) append(dateTimeFormatter.format(LocalDateTime.now()))
append(": ") append(": ")
if (e != null) { if (e != null) {
append("E!") append("E!")

View File

@@ -5,7 +5,7 @@ import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.list.domain.ListSortOrder 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 java.util.Date import java.time.Instant
@Parcelize @Parcelize
data class FavouriteCategory( data class FavouriteCategory(
@@ -13,7 +13,7 @@ data class FavouriteCategory(
val title: String, val title: String,
val sortKey: Int, val sortKey: Int,
val order: ListSortOrder, val order: ListSortOrder,
val createdAt: Date, val createdAt: Instant,
val isTrackingEnabled: Boolean, val isTrackingEnabled: Boolean,
val isVisibleInLibrary: Boolean, val isVisibleInLibrary: Boolean,
) : Parcelable, ListModel { ) : Parcelable, ListModel {

View File

@@ -1,16 +1,19 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.core.model
import android.net.Uri import android.net.Uri
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.R 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.ContentRating
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.model.MangaState
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import com.google.android.material.R as materialR
@JvmName("mangaIds") @JvmName("mangaIds")
fun Collection<Manga>.ids() = mapToSet { it.id } fun Collection<Manga>.ids() = mapToSet { it.id }
@@ -41,6 +44,25 @@ val MangaState.titleResId: Int
MangaState.FINISHED -> R.string.state_finished MangaState.FINISHED -> R.string.state_finished
MangaState.ABANDONED -> R.string.state_abandoned MangaState.ABANDONED -> R.string.state_abandoned
MangaState.PAUSED -> R.string.state_paused MangaState.PAUSED -> R.string.state_paused
MangaState.UPCOMING -> R.string.state_upcoming
}
@get:DrawableRes
val MangaState.iconResId: Int
get() = when (this) {
MangaState.ONGOING -> R.drawable.ic_play
MangaState.FINISHED -> R.drawable.ic_state_finished
MangaState.ABANDONED -> R.drawable.ic_state_abandoned
MangaState.PAUSED -> R.drawable.ic_action_pause
MangaState.UPCOMING -> materialR.drawable.ic_clock_black_24dp
}
@get:StringRes
val ContentRating.titleResId: Int
get() = when (this) {
ContentRating.SAFE -> R.string.rating_safe
ContentRating.SUGGESTIVE -> R.string.rating_suggestive
ContentRating.ADULT -> R.string.rating_adult
} }
fun Manga.findChapter(id: Long): MangaChapter? { fun Manga.findChapter(id: Long): MangaChapter? {

View File

@@ -2,14 +2,14 @@ package org.koitharu.kotatsu.core.model
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.util.* import java.time.Instant
@Parcelize @Parcelize
data class MangaHistory( data class MangaHistory(
val createdAt: Date, val createdAt: Instant,
val updatedAt: Date, val updatedAt: Instant,
val chapterId: Long, val chapterId: Long,
val page: Int, val page: Int,
val scroll: Int, val scroll: Int,
val percent: Float, val percent: Float,
) : Parcelable ) : Parcelable

View File

@@ -1,17 +1,23 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.core.model
import android.content.Context import android.content.Context
import android.graphics.Color
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import android.text.style.SuperscriptSpan
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getDisplayName
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.toLocale
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
import java.util.Locale import java.util.Locale
import com.google.android.material.R as materialR
fun MangaSource.getLocaleTitle(): String? {
val lc = Locale(locale ?: return null)
return lc.getDisplayLanguage(lc).toTitleCase(lc)
}
fun MangaSource(name: String): MangaSource { fun MangaSource(name: String): MangaSource {
MangaSource.entries.forEach { MangaSource.entries.forEach {
@@ -33,6 +39,24 @@ val ContentType.titleResId
fun MangaSource.getSummary(context: Context): String { fun MangaSource.getSummary(context: Context): String {
val type = context.getString(contentType.titleResId) val type = context.getString(contentType.titleResId)
val locale = getLocaleTitle() ?: context.getString(R.string.various_languages) val locale = locale?.toLocale().getDisplayName(context)
return context.getString(R.string.source_summary_pattern, type, locale) return context.getString(R.string.source_summary_pattern, type, locale)
} }
fun MangaSource.getTitle(context: Context): CharSequence = if (isNsfw()) {
buildSpannedString {
append(title)
append(' ')
appendNsfwLabel(context)
}
} else {
title
}
private fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)),
RelativeSizeSpan(0.74f),
SuperscriptSpan(),
) {
append(context.getString(R.string.nsfw))
}

View File

@@ -4,15 +4,11 @@ import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import java.text.SimpleDateFormat import java.time.Instant
import java.util.Date import java.time.ZonedDateTime
import java.util.Locale import java.time.format.DateTimeFormatter
import java.util.concurrent.TimeUnit
class RateLimitInterceptor : Interceptor { class RateLimitInterceptor : Interceptor {
private val dateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss ZZZ", Locale.ENGLISH)
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 == 429) { if (response.code == 429) {
@@ -27,10 +23,8 @@ class RateLimitInterceptor : Interceptor {
return response return response
} }
private fun String.parseRetryDate(): Date? { private fun String.parseRetryDate(): Instant? {
toIntOrNull()?.let { return toLongOrNull()?.let { Instant.now().plusSeconds(it) }
return Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(it.toLong())) ?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant()
}
return dateFormat.parse(this)
} }
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import androidx.core.net.toUri
import androidx.room.withTransaction import androidx.room.withTransaction
import dagger.Reusable import dagger.Reusable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -13,6 +14,7 @@ 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.model.isLocal
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
@@ -97,9 +99,18 @@ class MangaDataRepository @Inject constructor(
return db.getTagsDao().findTags(source.name).toMangaTags() return db.getTagsDao().findTags(source.name).toMangaTags()
} }
suspend fun cleanupLocalManga() {
val dao = db.getMangaDao()
val broken = dao.findAllBySource(MangaSource.LOCAL.name)
.filter { x -> x.manga.url.toUri().toFileOrNull()?.exists() == false }
if (broken.isNotEmpty()) {
dao.delete(broken.map { it.manga })
}
}
private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? { private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? {
return if (cfBrightness != 0f || cfContrast != 0f || cfInvert) { return if (cfBrightness != 0f || cfContrast != 0f || cfInvert || cfGrayscale) {
ReaderColorFilter(cfBrightness, cfContrast, cfInvert) ReaderColorFilter(cfBrightness, cfContrast, cfInvert, cfGrayscale)
} else { } else {
null null
} }
@@ -111,5 +122,6 @@ class MangaDataRepository @Inject constructor(
cfBrightness = 0f, cfBrightness = 0f,
cfContrast = 0f, cfContrast = 0f,
cfInvert = false, cfInvert = false,
cfGrayscale = false,
) )
} }

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import android.net.Uri import android.net.Uri
import coil.request.CachePolicy
import dagger.Reusable import dagger.Reusable
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
@@ -85,7 +86,7 @@ class MangaLinkResolver @Inject constructor(
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga { private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
return if (this is RemoteMangaRepository) { return if (this is RemoteMangaRepository) {
getDetails(manga, withCache = false) getDetails(manga, CachePolicy.READ_ONLY)
} else { } else {
getDetails(manga) getDetails(manga)
} }

View File

@@ -5,6 +5,7 @@ import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.local.data.LocalMangaRepository 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.ContentRating
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.MangaListFilter
@@ -15,6 +16,7 @@ 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
import java.util.EnumMap import java.util.EnumMap
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.collections.set import kotlin.collections.set
@@ -27,10 +29,16 @@ interface MangaRepository {
val states: Set<MangaState> val states: Set<MangaState>
val contentRatings: Set<ContentRating>
var defaultSortOrder: SortOrder var defaultSortOrder: SortOrder
val isMultipleTagsSupported: Boolean val isMultipleTagsSupported: Boolean
val isTagsExclusionSupported: Boolean
val isSearchSupported: Boolean
suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga>
suspend fun getDetails(manga: Manga): Manga suspend fun getDetails(manga: Manga): Manga
@@ -41,6 +49,8 @@ interface MangaRepository {
suspend fun getTags(): Set<MangaTag> suspend fun getTags(): Set<MangaTag>
suspend fun getLocales(): Set<Locale>
suspend fun getRelated(seed: Manga): List<Manga> suspend fun getRelated(seed: Manga): List<Manga>
@Singleton @Singleton

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import android.util.Log import android.util.Log
import coil.request.CachePolicy
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -20,6 +21,7 @@ import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.ContentRating
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
@@ -31,6 +33,7 @@ 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
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Locale
class RemoteMangaRepository( class RemoteMangaRepository(
private val parser: MangaParser, private val parser: MangaParser,
@@ -47,6 +50,9 @@ class RemoteMangaRepository(
override val states: Set<MangaState> override val states: Set<MangaState>
get() = parser.availableStates get() = parser.availableStates
override val contentRatings: Set<ContentRating>
get() = parser.availableContentRating
override var defaultSortOrder: SortOrder override var defaultSortOrder: SortOrder
get() = getConfig().defaultSortOrder ?: sortOrders.first() get() = getConfig().defaultSortOrder ?: sortOrders.first()
set(value) { set(value) {
@@ -56,6 +62,12 @@ class RemoteMangaRepository(
override val isMultipleTagsSupported: Boolean override val isMultipleTagsSupported: Boolean
get() = parser.isMultipleTagsSupported get() = parser.isMultipleTagsSupported
override val isSearchSupported: Boolean
get() = parser.isSearchSupported
override val isTagsExclusionSupported: Boolean
get() = parser.isTagsExclusionSupported
var domain: String var domain: String
get() = parser.domain get() = parser.domain
set(value) { set(value) {
@@ -82,7 +94,7 @@ class RemoteMangaRepository(
} }
} }
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, withCache = true) override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
cache.getPages(source, chapter.url)?.let { return it } cache.getPages(source, chapter.url)?.let { return it }
@@ -103,6 +115,10 @@ class RemoteMangaRepository(
parser.getAvailableTags() parser.getAvailableTags()
} }
override suspend fun getLocales(): Set<Locale> {
return parser.getAvailableLocales()
}
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching { suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getFavicons() parser.getFavicons()
} }
@@ -116,17 +132,18 @@ class RemoteMangaRepository(
return related.await() return related.await()
} }
suspend fun getDetails(manga: Manga, withCache: Boolean): Manga { suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga {
if (!withCache) { if (cachePolicy.readEnabled) {
return parser.getDetails(manga) cache.getDetails(source, manga.url)?.let { return it }
} }
cache.getDetails(source, manga.url)?.let { return it }
val details = asyncSafe { val details = asyncSafe {
mirrorSwitchInterceptor.withMirrorSwitching { mirrorSwitchInterceptor.withMirrorSwitching {
parser.getDetails(manga) parser.getDetails(manga)
} }
} }
cache.putDetails(source, manga.url, details) if (cachePolicy.writeEnabled) {
cache.putDetails(source, manga.url, details)
}
return details.await() return details.await()
} }
@@ -149,6 +166,10 @@ class RemoteMangaRepository(
return parser.configKeyDomain.presetValues.toList() return parser.configKeyDomain.presetValues.toList()
} }
fun isSlowdownEnabled(): Boolean {
return getConfig().isSlowdownEnabled
}
private fun getConfig() = parser.config as SourceSettings private fun getConfig() = parser.config as SourceSettings
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> { private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {

View File

@@ -28,6 +28,7 @@ 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
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import java.io.File import java.io.File
import java.net.Proxy import java.net.Proxy
import java.util.Locale import java.util.Locale
@@ -203,6 +204,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isUnstableUpdatesAllowed: Boolean val isUnstableUpdatesAllowed: Boolean
get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false) get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false)
val defaultDetailsTab: Int
get() = prefs.getString(KEY_DETAILS_TAB, null)?.toIntOrNull()?.coerceIn(0, 1) ?: 0
val isContentPrefetchEnabled: Boolean val isContentPrefetchEnabled: Boolean
get() { get() {
if (isBackgroundNetworkRestricted()) { if (isBackgroundNetworkRestricted()) {
@@ -259,9 +263,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
} }
} }
val isDownloadsSlowdownEnabled: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_SLOWDOWN, false)
val isDownloadsWiFiOnly: Boolean val isDownloadsWiFiOnly: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false) get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false)
@@ -296,6 +297,24 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderKeepScreenOn: Boolean val isReaderKeepScreenOn: Boolean
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true) get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
var readerColorFilter: ReaderColorFilter?
get() = runCatching {
val brightness = prefs.getFloat(KEY_CF_BRIGHTNESS, ReaderColorFilter.EMPTY.brightness)
val contrast = prefs.getFloat(KEY_CF_CONTRAST, ReaderColorFilter.EMPTY.contrast)
val inverted = prefs.getBoolean(KEY_CF_INVERTED, ReaderColorFilter.EMPTY.isInverted)
val grayscale = prefs.getBoolean(KEY_CF_GRAYSCALE, ReaderColorFilter.EMPTY.isGrayscale)
ReaderColorFilter(brightness, contrast, inverted, grayscale).takeUnless { it.isEmpty }
}.getOrNull()
set(value) {
prefs.edit {
val cf = value ?: ReaderColorFilter.EMPTY
putFloat(KEY_CF_BRIGHTNESS, cf.brightness)
putFloat(KEY_CF_CONTRAST, cf.contrast)
putBoolean(KEY_CF_INVERTED, cf.isInverted)
putBoolean(KEY_CF_GRAYSCALE, cf.isGrayscale)
}
}
val isImagesProxyEnabled: Boolean val isImagesProxyEnabled: Boolean
get() = prefs.getBoolean(KEY_IMAGES_PROXY, false) get() = prefs.getBoolean(KEY_IMAGES_PROXY, false)
@@ -495,7 +514,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SHIKIMORI = "shikimori" const val KEY_SHIKIMORI = "shikimori"
const val KEY_ANILIST = "anilist" const val KEY_ANILIST = "anilist"
const val KEY_MAL = "mal" const val KEY_MAL = "mal"
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
const val KEY_DOWNLOADS_WIFI = "downloads_wifi" const val KEY_DOWNLOADS_WIFI = "downloads_wifi"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
const val KEY_DOH = "doh" const val KEY_DOH = "doh"
@@ -539,6 +557,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_32BIT_COLOR = "enhanced_colors" const val KEY_32BIT_COLOR = "enhanced_colors"
const val KEY_SOURCES_ORDER = "sources_sort_order" const val KEY_SOURCES_ORDER = "sources_sort_order"
const val KEY_SOURCES_CATALOG = "sources_catalog" const val KEY_SOURCES_CATALOG = "sources_catalog"
const val KEY_CF_BRIGHTNESS = "cf_brightness"
const val KEY_CF_CONTRAST = "cf_contrast"
const val KEY_CF_INVERTED = "cf_inverted"
const val KEY_CF_GRAYSCALE = "cf_grayscale"
const val KEY_IGNORE_DOZE = "ignore_dose"
const val KEY_DETAILS_TAB = "details_tab"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

View File

@@ -11,6 +11,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
private const val KEY_SORT_ORDER = "sort_order" private const val KEY_SORT_ORDER = "sort_order"
private const val KEY_SLOWDOWN = "slowdown"
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig { class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
@@ -20,6 +21,9 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java) get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java)
set(value) = prefs.edit { putEnumValue(KEY_SORT_ORDER, value) } set(value) = prefs.edit { putEnumValue(KEY_SORT_ORDER, value) }
val isSlowdownEnabled: Boolean
get() = prefs.getBoolean(KEY_SLOWDOWN, false)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T> get(key: ConfigKey<T>): T { override fun <T> get(key: ConfigKey<T>): T {
return when (key) { return when (key) {

View File

@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.core.ui package org.koitharu.kotatsu.core.ui
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
@@ -8,7 +10,9 @@ import androidx.core.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
@@ -62,4 +66,12 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
protected fun setTitle(title: CharSequence?) { protected fun setTitle(title: CharSequence?) {
(activity as? SettingsActivity)?.setSectionTitle(title) (activity as? SettingsActivity)?.setSectionTitle(title)
} }
protected fun startActivitySafe(intent: Intent) {
try {
startActivity(intent)
} catch (_: ActivityNotFoundException) {
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
}
}
} }

View File

@@ -0,0 +1,75 @@
package org.koitharu.kotatsu.core.ui
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListModel
import java.util.Collections
import java.util.LinkedList
open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>>(), FlowCollector<List<T>?> {
private val listListeners = LinkedList<ListListener<T>>()
override suspend fun emit(value: List<T>?) {
val oldList = items.orEmpty()
val newList = value.orEmpty()
val diffResult = withContext(Dispatchers.Default) {
val diffCallback = DiffCallback(oldList, newList)
DiffUtil.calculateDiff(diffCallback)
}
super.setItems(newList)
diffResult.dispatchUpdatesTo(this)
listListeners.forEach { it.onCurrentListChanged(oldList, newList) }
}
@Deprecated("Use emit() to dispatch list updates", level = DeprecationLevel.ERROR)
override fun setItems(items: List<T>?) {
super.setItems(items)
}
fun reorderItems(oldPos: Int, newPos: Int) {
Collections.swap(items ?: return, oldPos, newPos)
notifyItemMoved(oldPos, newPos)
}
fun addDelegate(type: ListItemType, delegate: AdapterDelegate<List<T>>): ReorderableListAdapter<T> {
delegatesManager.addDelegate(type.ordinal, delegate)
return this
}
fun addListListener(listListener: ListListener<T>) {
listListeners.add(listListener)
}
fun removeListListener(listListener: ListListener<T>) {
listListeners.remove(listListener)
}
protected class DiffCallback<T : ListModel>(
val oldList: List<T>,
val newList: List<T>,
) : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldList.size
override fun getNewListSize(): Int = newList.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return newItem.areItemsTheSame(oldItem)
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return newItem == oldItem
}
}
}

View File

@@ -1,100 +0,0 @@
package org.koitharu.kotatsu.core.ui.drawable
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.os.Build
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import androidx.annotation.ColorInt
import androidx.annotation.Px
import androidx.annotation.StyleRes
import androidx.core.graphics.withTranslation
import com.google.android.material.resources.TextAppearance
import com.google.android.material.resources.TextAppearanceFontCallback
import org.koitharu.kotatsu.core.util.ext.getThemeColor
class TextDrawable(
val text: CharSequence,
) : Drawable() {
private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG)
private var cachedLayout: StaticLayout? = null
@SuppressLint("RestrictedApi")
constructor(context: Context, text: CharSequence, @StyleRes textAppearanceId: Int) : this(text) {
val ta = TextAppearance(context, textAppearanceId)
paint.color = ta.textColor?.defaultColor ?: context.getThemeColor(android.R.attr.textColorPrimary, Color.BLACK)
paint.typeface = ta.fallbackFont
ta.getFontAsync(
context, paint,
object : TextAppearanceFontCallback() {
override fun onFontRetrieved(typeface: Typeface?, fontResolvedSynchronously: Boolean) = Unit
override fun onFontRetrievalFailed(reason: Int) = Unit
},
)
paint.letterSpacing = ta.letterSpacing
}
var alignment = Layout.Alignment.ALIGN_NORMAL
var lineSpacingMultiplier = 1f
@Px
var lineSpacingExtra = 0f
@get:ColorInt
var textColor: Int
get() = paint.color
set(@ColorInt value) {
paint.color = value
}
override fun draw(canvas: Canvas) {
val b = bounds
if (b.isEmpty) {
return
}
canvas.withTranslation(x = b.left.toFloat(), y = b.top.toFloat()) {
obtainLayout().draw(canvas)
}
}
override fun setAlpha(alpha: Int) {
paint.alpha = alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.setColorFilter(colorFilter)
}
@Suppress("DeprecatedCallableAddReplaceWith")
@Deprecated("Deprecated in Java")
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
private fun obtainLayout(): StaticLayout {
val width = bounds.width()
cachedLayout?.let {
if (it.width == width) {
return it
}
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
.setAlignment(alignment)
.setLineSpacing(lineSpacingExtra, lineSpacingMultiplier)
.setIncludePad(true)
.build()
} else {
@Suppress("DEPRECATION")
StaticLayout(text, paint, width, alignment, lineSpacingMultiplier, lineSpacingExtra, true)
}.also { cachedLayout = it }
}
}

View File

@@ -38,4 +38,12 @@ abstract class BoundsScrollListener(
firstVisibleItemPosition: Int, firstVisibleItemPosition: Int,
visibleItemCount: Int visibleItemCount: Int
) = Unit ) = Unit
fun invalidate(recyclerView: RecyclerView) {
onScrolled(recyclerView, 0, 0)
}
fun postInvalidate(recyclerView: RecyclerView) = recyclerView.post {
invalidate(recyclerView)
}
} }

View File

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

View File

@@ -1,237 +1,4 @@
package org.koitharu.kotatsu.core.ui.list package org.koitharu.kotatsu.core.ui.list
import android.app.Activity
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.collection.ArrayMap
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryOwner
import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import kotlin.coroutines.EmptyCoroutineContext
private const val PROVIDER_NAME = "selection_decoration_sectioned" private const val PROVIDER_NAME = "selection_decoration_sectioned"
class SectionedSelectionController<T : Any>(
private val activity: Activity,
private val owner: SavedStateRegistryOwner,
private val callback: Callback<T>,
) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
private var actionMode: ActionMode? = null
private var pendingData: MutableMap<String, Collection<Long>>? = null
private val decorations = ArrayMap<T, AbstractSelectionItemDecoration>()
val count: Int
get() = decorations.values.sumOf { it.checkedItemsCount }
init {
owner.lifecycle.addObserver(StateEventObserver())
}
fun snapshot(): Map<T, Set<Long>> {
return decorations.mapValues { it.value.checkedItemsIds.toSet() }
}
fun peekCheckedIds(): Map<T, Set<Long>> {
return decorations.mapValues { it.value.checkedItemsIds }
}
fun clear() {
decorations.values.forEach {
it.clearSelection()
}
notifySelectionChanged()
}
fun attachToRecyclerView(section: T, recyclerView: RecyclerView) {
val decoration = getDecoration(section)
val pendingIds = pendingData?.remove(section.toString())
if (!pendingIds.isNullOrEmpty()) {
decoration.checkAll(pendingIds)
startActionMode()
notifySelectionChanged()
}
var shouldAddDecoration = true
for (i in (0 until recyclerView.itemDecorationCount).reversed()) {
val decor = recyclerView.getItemDecorationAt(i)
if (decor === decoration) {
shouldAddDecoration = false
break
} else if (decor.javaClass == decoration.javaClass) {
recyclerView.removeItemDecorationAt(i)
}
}
if (shouldAddDecoration) {
recyclerView.addItemDecoration(decoration)
}
if (pendingData?.isEmpty() == true) {
pendingData = null
}
}
override fun saveState(): Bundle {
val bundle = Bundle(decorations.size)
for ((k, v) in decorations) {
bundle.putLongArray(k.toString(), v.checkedItemsIds.toLongArray())
}
return bundle
}
fun onItemClick(section: T, id: Long): Boolean {
val decoration = getDecoration(section)
if (isInSelectionMode()) {
decoration.toggleItemChecked(id)
if (isInSelectionMode()) {
actionMode?.invalidate()
} else {
actionMode?.finish()
}
notifySelectionChanged()
return true
}
return false
}
fun onItemLongClick(section: T, id: Long): Boolean {
val decoration = getDecoration(section)
startActionMode()
return actionMode?.also {
decoration.setItemIsChecked(id, true)
notifySelectionChanged()
} != null
}
fun getSectionCount(section: T): Int {
return decorations[section]?.checkedItemsCount ?: 0
}
fun addToSelection(section: T, ids: Collection<Long>): Boolean {
val decoration = getDecoration(section)
startActionMode()
return actionMode?.also {
decoration.checkAll(ids)
notifySelectionChanged()
} != null
}
fun clearSelection(section: T) {
decorations[section]?.clearSelection() ?: return
notifySelectionChanged()
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
return callback.onCreateActionMode(this, mode, menu)
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
return callback.onPrepareActionMode(this, mode, menu)
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return callback.onActionItemClicked(this, mode, item)
}
override fun onDestroyActionMode(mode: ActionMode) {
callback.onDestroyActionMode(this, mode)
clear()
actionMode = null
}
private fun startActionMode() {
if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
}
}
private fun isInSelectionMode(): Boolean {
return decorations.values.any { x -> x.checkedItemsCount > 0 }
}
private fun notifySelectionChanged() {
val count = this.count
callback.onSelectionChanged(this, count)
if (count == 0) {
actionMode?.finish()
} else {
actionMode?.invalidate()
}
}
private fun restoreState(ids: MutableMap<String, Collection<Long>>) {
if (ids.isEmpty() || isInSelectionMode()) {
return
}
for ((k, v) in decorations) {
val items = ids.remove(k.toString())
if (!items.isNullOrEmpty()) {
v.checkAll(items)
}
}
pendingData = ids
if (isInSelectionMode()) {
startActionMode()
notifySelectionChanged()
}
}
private fun getDecoration(section: T): AbstractSelectionItemDecoration {
return decorations.getOrPut(section) {
callback.onCreateItemDecoration(this, section)
}
}
interface Callback<T : Any> {
fun onSelectionChanged(controller: SectionedSelectionController<T>, count: Int)
fun onCreateActionMode(controller: SectionedSelectionController<T>, mode: ActionMode, menu: Menu): Boolean
fun onPrepareActionMode(controller: SectionedSelectionController<T>, mode: ActionMode, menu: Menu): Boolean {
mode.title = controller.count.toString()
return true
}
fun onDestroyActionMode(controller: SectionedSelectionController<T>, mode: ActionMode) = Unit
fun onActionItemClicked(
controller: SectionedSelectionController<T>,
mode: ActionMode,
item: MenuItem,
): Boolean
fun onCreateItemDecoration(
controller: SectionedSelectionController<T>,
section: T,
): AbstractSelectionItemDecoration
}
private inner class StateEventObserver : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_CREATE) {
val registry = owner.savedStateRegistry
registry.registerSavedStateProvider(PROVIDER_NAME, this@SectionedSelectionController)
val state = registry.consumeRestoredStateForKey(PROVIDER_NAME)
if (state != null) {
Dispatchers.Main.dispatch(EmptyCoroutineContext) { // == Handler.post
if (source.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
restoreState(
state.keySet()
.associateWithTo(HashMap()) { state.getLongArray(it)?.toList().orEmpty() },
)
}
}
}
}
}
}
}

View File

@@ -12,7 +12,12 @@ import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.* import android.widget.*
import androidx.annotation.* import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.DimenRes
import androidx.annotation.DrawableRes
import androidx.annotation.Px
import androidx.annotation.StyleableRes
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet import androidx.constraintlayout.widget.ConstraintSet
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
@@ -131,19 +136,19 @@ class FastScroller @JvmOverloads constructor(
var showTrack = false var showTrack = false
context.withStyledAttributes(attrs, R.styleable.FastScroller, defStyleAttr) { context.withStyledAttributes(attrs, R.styleable.FastScrollRecyclerView, defStyleAttr) {
bubbleColor = getColor(R.styleable.FastScroller_bubbleColor, bubbleColor) bubbleColor = getColor(R.styleable.FastScrollRecyclerView_bubbleColor, bubbleColor)
handleColor = getColor(R.styleable.FastScroller_thumbColor, handleColor) handleColor = getColor(R.styleable.FastScrollRecyclerView_thumbColor, handleColor)
trackColor = getColor(R.styleable.FastScroller_trackColor, trackColor) trackColor = getColor(R.styleable.FastScrollRecyclerView_trackColor, trackColor)
textColor = getColor(R.styleable.FastScroller_bubbleTextColor, textColor) textColor = getColor(R.styleable.FastScrollRecyclerView_bubbleTextColor, textColor)
hideScrollbar = getBoolean(R.styleable.FastScroller_hideScrollbar, hideScrollbar) hideScrollbar = getBoolean(R.styleable.FastScrollRecyclerView_hideScrollbar, hideScrollbar)
showBubble = getBoolean(R.styleable.FastScroller_showBubble, showBubble) showBubble = getBoolean(R.styleable.FastScrollRecyclerView_showBubble, showBubble)
showBubbleAlways = getBoolean(R.styleable.FastScroller_showBubbleAlways, showBubbleAlways) showBubbleAlways = getBoolean(R.styleable.FastScrollRecyclerView_showBubbleAlways, showBubbleAlways)
showTrack = getBoolean(R.styleable.FastScroller_showTrack, showTrack) showTrack = getBoolean(R.styleable.FastScrollRecyclerView_showTrack, showTrack)
bubbleSize = getBubbleSize(R.styleable.FastScroller_bubbleSize, BubbleSize.NORMAL) bubbleSize = getBubbleSize(R.styleable.FastScrollRecyclerView_bubbleSize, BubbleSize.NORMAL)
val textSize = getDimension(R.styleable.FastScroller_bubbleTextSize, bubbleSize.textSize) val textSize = getDimension(R.styleable.FastScrollRecyclerView_bubbleTextSize, bubbleSize.textSize)
binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize) binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
offset = getDimensionPixelOffset(R.styleable.FastScroller_scrollerOffset, offset) offset = getDimensionPixelOffset(R.styleable.FastScrollRecyclerView_scrollerOffset, offset)
} }
setTrackColor(trackColor) setTrackColor(trackColor)

View File

@@ -2,9 +2,8 @@ package org.koitharu.kotatsu.core.ui.model
import android.content.res.Resources import android.content.res.Resources
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.daysDiff import java.time.LocalDate
import org.koitharu.kotatsu.core.util.ext.format import java.time.format.DateTimeFormatter
import java.util.Date
sealed class DateTimeAgo { sealed class DateTimeAgo {
@@ -74,28 +73,22 @@ sealed class DateTimeAgo {
} }
} }
class Absolute(private val date: Date) : DateTimeAgo() { data class Absolute(private val date: LocalDate) : DateTimeAgo() {
private val day = date.daysDiff(0)
override fun format(resources: Resources): String { override fun format(resources: Resources): String {
return date.format("d MMMM") return if (date == EPOCH_DATE) {
resources.getString(R.string.unknown)
} else {
date.format(formatter)
}
} }
override fun equals(other: Any?): Boolean { override fun toString() = "abs_${date.toEpochDay()}"
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Absolute companion object {
// TODO: Use Java 9's LocalDate.EPOCH.
return day == other.day private val EPOCH_DATE = LocalDate.of(1970, 1, 1)
private val formatter = DateTimeFormatter.ofPattern("d MMMM")
} }
override fun hashCode(): Int {
return day
}
override fun toString() = "abs_$day"
} }
object LongAgo : DateTimeAgo() { object LongAgo : DateTimeAgo() {

View File

@@ -12,4 +12,5 @@ val SortOrder.titleRes: Int
SortOrder.RATING -> R.string.by_rating SortOrder.RATING -> R.string.by_rating
SortOrder.NEWEST -> R.string.newest SortOrder.NEWEST -> R.string.newest
SortOrder.ALPHABETICAL -> R.string.by_name SortOrder.ALPHABETICAL -> R.string.by_name
SortOrder.ALPHABETICAL_DESC -> R.string.by_name_reverse
} }

View File

@@ -70,7 +70,7 @@ class WindowInsetsDelegate : OnApplyWindowInsetsListener, View.OnLayoutChangeLis
lastInsets = null lastInsets = null
} }
interface WindowInsetsListener { fun interface WindowInsetsListener {
fun onWindowInsetsChanged(insets: Insets) fun onWindowInsetsChanged(insets: Insets)
} }

View File

@@ -1,21 +1,16 @@
package org.koitharu.kotatsu.core.ui.widgets package org.koitharu.kotatsu.core.ui.widgets
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.res.ColorStateList
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View.OnClickListener import android.view.View.OnClickListener
import androidx.annotation.ColorRes import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import androidx.core.content.res.getColorStateListOrThrow
import androidx.core.view.children import androidx.core.view.children
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipDrawable
import com.google.android.material.chip.ChipGroup import com.google.android.material.chip.ChipGroup
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.castOrNull import org.koitharu.kotatsu.core.util.ext.castOrNull
import com.google.android.material.R as materialR
class ChipsView @JvmOverloads constructor( class ChipsView @JvmOverloads constructor(
context: Context, context: Context,
@@ -31,9 +26,7 @@ class ChipsView @JvmOverloads constructor(
private val chipOnCloseListener = OnClickListener { private val chipOnCloseListener = OnClickListener {
onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag) onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag)
} }
private val defaultChipStrokeColor: ColorStateList private val chipStyle: Int
private val defaultChipTextColor: ColorStateList
private val defaultChipIconTint: ColorStateList
var onChipClickListener: OnChipClickListener? = null var onChipClickListener: OnChipClickListener? = null
set(value) { set(value) {
field = value field = value
@@ -48,12 +41,17 @@ class ChipsView @JvmOverloads constructor(
} }
init { init {
@SuppressLint("CustomViewStyleable") val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0)
val a = context.obtainStyledAttributes(null, materialR.styleable.Chip, 0, R.style.Widget_Kotatsu_Chip) chipStyle = ta.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip)
defaultChipStrokeColor = a.getColorStateListOrThrow(materialR.styleable.Chip_chipStrokeColor) ta.recycle()
defaultChipTextColor = a.getColorStateListOrThrow(materialR.styleable.Chip_android_textColor)
defaultChipIconTint = a.getColorStateListOrThrow(materialR.styleable.Chip_chipIconTint) if (isInEditMode) {
a.recycle() setChips(
List(5) {
ChipModel(0, "Chip $it", 0, isCheckable = false, isChecked = false)
},
)
}
} }
override fun requestLayout() { override fun requestLayout() {
@@ -91,15 +89,6 @@ class ChipsView @JvmOverloads constructor(
private fun bindChip(chip: Chip, model: ChipModel) { private fun bindChip(chip: Chip, model: ChipModel) {
chip.text = model.title chip.text = model.title
val tint = if (model.tint == 0) {
null
} else {
ContextCompat.getColorStateList(context, model.tint)
}
chip.chipIconTint = tint ?: defaultChipIconTint
chip.checkedIconTint = tint ?: defaultChipIconTint
chip.chipStrokeColor = tint ?: defaultChipStrokeColor
chip.setTextColor(tint ?: defaultChipTextColor)
chip.isClickable = onChipClickListener != null || model.isCheckable chip.isClickable = onChipClickListener != null || model.isCheckable
chip.isCheckable = model.isCheckable chip.isCheckable = model.isCheckable
if (model.icon == 0) { if (model.icon == 0) {
@@ -115,12 +104,10 @@ class ChipsView @JvmOverloads constructor(
private fun addChip(): Chip { private fun addChip(): Chip {
val chip = Chip(context) val chip = Chip(context)
val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip) val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
chip.setChipDrawable(drawable) chip.setChipDrawable(drawable)
chip.isCheckedIconVisible = true chip.isCheckedIconVisible = true
chip.isChipIconVisible = false chip.isChipIconVisible = false
chip.setCheckedIconResource(R.drawable.ic_check)
chip.checkedIconTint = defaultChipIconTint
chip.isCloseIconVisible = onChipCloseClickListener != null chip.isCloseIconVisible = onChipCloseClickListener != null
chip.setOnCloseIconClickListener(chipOnCloseListener) chip.setOnCloseIconClickListener(chipOnCloseListener)
chip.setEnsureMinTouchTargetSize(false) chip.setEnsureMinTouchTargetSize(false)

View File

@@ -0,0 +1,44 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.core.content.withStyledAttributes
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
class NestedRecyclerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : RecyclerView(context, attrs) {
private var maxHeight: Int = 0
init {
context.withStyledAttributes(attrs, R.styleable.NestedRecyclerView) {
maxHeight = getDimensionPixelSize(R.styleable.NestedRecyclerView_maxHeight, maxHeight)
}
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(e: MotionEvent?): Boolean {
if (e?.actionMasked == MotionEvent.ACTION_UP) {
requestDisallowInterceptTouchEvent(false)
} else {
requestDisallowInterceptTouchEvent(true)
}
return super.onTouchEvent(e)
}
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
super.onMeasure(
widthSpec,
if (maxHeight == 0) {
heightSpec
} else {
MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST)
},
)
}
}

View File

@@ -22,7 +22,6 @@ import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ViewTipBinding import org.koitharu.kotatsu.databinding.ViewTipBinding
import com.google.android.material.R as materialR
class TipView @JvmOverloads constructor( class TipView @JvmOverloads constructor(
context: Context, context: Context,

View File

@@ -9,10 +9,8 @@ import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks
import org.acra.ACRA import org.acra.ACRA
import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks
import java.text.DateFormat import java.time.LocalTime
import java.text.SimpleDateFormat import java.time.temporal.ChronoUnit
import java.util.Date
import java.util.Locale
import java.util.WeakHashMap import java.util.WeakHashMap
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -20,7 +18,6 @@ import javax.inject.Singleton
@Singleton @Singleton
class AcraScreenLogger @Inject constructor() : FragmentLifecycleCallbacks(), DefaultActivityLifecycleCallbacks { class AcraScreenLogger @Inject constructor() : FragmentLifecycleCallbacks(), DefaultActivityLifecycleCallbacks {
private val timeFormat = SimpleDateFormat.getTimeInstance(DateFormat.DEFAULT, Locale.ROOT)
private val keys = WeakHashMap<Any, String>() private val keys = WeakHashMap<Any, String>()
override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) { override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) {
@@ -47,11 +44,10 @@ class AcraScreenLogger @Inject constructor() : FragmentLifecycleCallbacks(), Def
} }
private fun Any.key() = keys.getOrPut(this) { private fun Any.key() = keys.getOrPut(this) {
"${time()}: ${javaClass.simpleName}" val time = LocalTime.now().truncatedTo(ChronoUnit.SECONDS)
"$time: ${javaClass.simpleName}"
} }
private fun time() = timeFormat.format(Date())
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
private fun Bundle?.contentToString() = this?.keySet()?.joinToString { k -> private fun Bundle?.contentToString() = this?.keySet()?.joinToString { k ->
val v = get(k) val v = get(k)

View File

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

View File

@@ -21,7 +21,11 @@ class ViewBadge(
get() = badgeDrawable?.number ?: 0 get() = badgeDrawable?.number ?: 0
set(value) { set(value) {
val badge = badgeDrawable ?: initBadge() val badge = badgeDrawable ?: initBadge()
badge.number = value if (maxCharacterCount != 0) {
badge.number = value
} else {
badge.clearNumber()
}
badge.isVisible = value > 0 badge.isVisible = value > 0
} }
@@ -51,7 +55,13 @@ class ViewBadge(
fun setMaxCharacterCount(value: Int) { fun setMaxCharacterCount(value: Int) {
maxCharacterCount = value maxCharacterCount = value
badgeDrawable?.maxCharacterCount = value badgeDrawable?.let {
if (value == 0) {
it.clearNumber()
} else {
it.maxCharacterCount = value
}
}
} }
private fun initBadge(): BadgeDrawable { private fun initBadge(): BadgeDrawable {

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core.util package org.koitharu.kotatsu.core.util
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.lifecycle.asFlow
import androidx.work.WorkInfo import androidx.work.WorkInfo
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkQuery import androidx.work.WorkQuery
@@ -27,8 +26,7 @@ class WorkServiceStopHelper(
fun setup() { fun setup() {
processLifecycleScope.launch(Dispatchers.Default) { processLifecycleScope.launch(Dispatchers.Default) {
workManagerProvider.get() workManagerProvider.get()
.getWorkInfosLiveData(WorkQuery.fromStates(WorkInfo.State.RUNNING)) .getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING))
.asFlow()
.map { it.isEmpty() } .map { it.isEmpty() }
.distinctUntilChanged() .distinctUntilChanged()
.collectLatest { .collectLatest {

View File

@@ -17,6 +17,7 @@ import android.content.SyncResult
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ResolveInfo import android.content.pm.ResolveInfo
import android.database.SQLException import android.database.SQLException
import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
@@ -29,6 +30,7 @@ import android.view.Window
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.IntegerRes import androidx.annotation.IntegerRes
import androidx.annotation.WorkerThread
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -37,6 +39,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope import androidx.lifecycle.coroutineScope
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import com.google.android.material.elevation.ElevationOverlayProvider import com.google.android.material.elevation.ElevationOverlayProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -45,7 +48,9 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import okio.IOException import okio.IOException
import okio.use
import org.json.JSONException import org.json.JSONException
import org.jsoup.internal.StringUtil.StringJoiner import org.jsoup.internal.StringUtil.StringJoiner
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
@@ -53,6 +58,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException import org.xmlpull.v1.XmlPullParserException
import java.io.File
import kotlin.math.roundToLong import kotlin.math.roundToLong
val Context.activityManager: ActivityManager? val Context.activityManager: ActivityManager?
@@ -130,7 +136,7 @@ fun Window.setNavigationBarTransparentCompat(context: Context, elevation: Float,
} else { } else {
// Set navbar scrim 70% of navigationBarColor // Set navbar scrim 70% of navigationBarColor
ElevationOverlayProvider(context).compositeOverlayIfNeeded( ElevationOverlayProvider(context).compositeOverlayIfNeeded(
context.getThemeColor(android.R.attr.navigationBarColor, alphaFactor), context.getThemeColor(R.attr.m3ColorBottomMenuBackground, alphaFactor),
elevation, elevation,
) )
} }
@@ -230,3 +236,18 @@ fun Context.checkNotificationPermission(): Boolean = if (Build.VERSION.SDK_INT >
} else { } else {
NotificationManagerCompat.from(this).areNotificationsEnabled() NotificationManagerCompat.from(this).areNotificationsEnabled()
} }
@WorkerThread
suspend fun Bitmap.compressToPNG(output: File) = runInterruptible(Dispatchers.IO) {
output.outputStream().use { os ->
if (!compress(Bitmap.CompressFormat.PNG, 100, os)) {
throw IOException("Failed to encode bitmap into PNG format")
}
}
}
fun Context.ensureRamAtLeast(requiredSize: Long) {
if (ramAvailable < requiredSize) {
throw IllegalStateException("Not enough free memory")
}
}

View File

@@ -50,7 +50,7 @@ private fun getVolumePathBeforeAndroid11(volumeId: String, context: Context): St
val length = ArrayReflect.getLength(checkNotNull(result)) val length = ArrayReflect.getLength(checkNotNull(result))
(0 until length).firstNotNullOfOrNull { i -> (0 until length).firstNotNullOfOrNull { i ->
val storageVolumeElement = ArrayReflect.get(result, i) val storageVolumeElement = ArrayReflect.get(result, i)
val uuid = getUuid.invoke(storageVolumeElement) as String val uuid = getUuid.invoke(storageVolumeElement) as String?
val primary = isPrimary.invoke(storageVolumeElement) as Boolean val primary = isPrimary.invoke(storageVolumeElement) as Boolean
when { when {
primary && volumeId == PRIMARY_VOLUME_NAME -> getPath.invoke(storageVolumeElement) as String primary && volumeId == PRIMARY_VOLUME_NAME -> getPath.invoke(storageVolumeElement) as String

View File

@@ -1,30 +1,32 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import android.annotation.SuppressLint import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import android.text.format.DateUtils import java.time.Instant
import java.text.SimpleDateFormat import java.time.LocalDate
import java.util.* import java.time.LocalDateTime
import java.util.concurrent.TimeUnit import java.time.ZoneId
import java.time.temporal.ChronoUnit
@SuppressLint("SimpleDateFormat") fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo {
fun Date.format(pattern: String): String = SimpleDateFormat(pattern).format(this) // TODO: Use Java 9's LocalDate.ofInstant().
val localDate = LocalDateTime.ofInstant(instant, ZoneId.systemDefault()).toLocalDate()
val now = LocalDate.now()
val diffDays = localDate.until(now, ChronoUnit.DAYS)
fun Date.formatRelative(minResolution: Long): CharSequence = DateUtils.getRelativeTimeSpanString( return when {
time, System.currentTimeMillis(), minResolution, diffDays == 0L -> {
) if (instant.until(Instant.now(), ChronoUnit.MINUTES) < 3) DateTimeAgo.JustNow
else DateTimeAgo.Today
fun Date.daysDiff(other: Long): Int { }
val thisDay = time / TimeUnit.DAYS.toMillis(1L) diffDays == 1L -> DateTimeAgo.Yesterday
val otherDay = other / TimeUnit.DAYS.toMillis(1L) diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays.toInt())
return (thisDay - otherDay).toInt() else -> {
} val diffMonths = localDate.until(now, ChronoUnit.MONTHS)
if (showMonths && diffMonths <= 6) {
fun Date.startOfDay(): Long { DateTimeAgo.MonthsAgo(diffMonths.toInt())
val calendar = Calendar.getInstance() } else {
calendar.time = this DateTimeAgo.Absolute(localDate)
calendar[Calendar.HOUR_OF_DAY] = 0 }
calendar[Calendar.MINUTE] = 0 }
calendar[Calendar.SECOND] = 0 }
calendar[Calendar.MILLISECOND] = 0
return calendar.timeInMillis
} }

View File

@@ -20,12 +20,13 @@ 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 { fun String.toLocale() = Locale(this)
fun Locale?.getDisplayName(context: Context): String {
if (this == null) { if (this == null) {
return context.getString(R.string.various_languages) return context.getString(R.string.various_languages)
} }
val lc = Locale(this) return getDisplayLanguage(this).toTitleCase(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> {

View File

@@ -3,18 +3,18 @@ package org.koitharu.kotatsu.core.util.ext
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import android.util.TypedValue
import androidx.annotation.Px import androidx.annotation.Px
import androidx.core.util.TypedValueCompat
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Px @Px
fun Resources.resolveDp(dp: Int) = (dp * displayMetrics.density).roundToInt() fun Resources.resolveDp(dp: Int) = resolveDp(dp.toFloat()).roundToInt()
@Px @Px
fun Resources.resolveDp(dp: Float) = dp * displayMetrics.density fun Resources.resolveDp(dp: Float) = TypedValueCompat.dpToPx(dp, displayMetrics)
@Px @Px
fun Resources.resolveSp(sp: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, displayMetrics) fun Resources.resolveSp(sp: Float) = TypedValueCompat.spToPx(sp, displayMetrics)
@SuppressLint("DiscouragedApi") @SuppressLint("DiscouragedApi")
fun Context.getSystemBoolean(resName: String, fallback: Boolean): Boolean { fun Context.getSystemBoolean(resName: String, fallback: Boolean): Boolean {

View File

@@ -19,6 +19,11 @@ import org.koitharu.kotatsu.core.exceptions.SyncApiException
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_STATES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.SEARCH_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
@@ -28,9 +33,6 @@ 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_NOT_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)
@@ -85,9 +87,11 @@ private fun getDisplayMessage(msg: String?, resources: Resources): String? = whe
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_NOT_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 == FILTER_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 == FILTER_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) msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported)
msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported)
else -> null else -> null
} }

View File

@@ -8,6 +8,8 @@ import android.view.View.MeasureSpec
import android.view.ViewGroup import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.Checkable import android.widget.Checkable
import androidx.appcompat.widget.ActionMenuView
import androidx.appcompat.widget.Toolbar
import androidx.core.view.children 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
@@ -104,6 +106,7 @@ fun RecyclerView.invalidateNestedItemDecorations() {
val View.parentView: ViewGroup? val View.parentView: ViewGroup?
get() = parent as? ViewGroup get() = parent as? ViewGroup
@Suppress("UnusedReceiverParameter")
fun View.measureDimension(desiredSize: Int, measureSpec: Int): Int { fun View.measureDimension(desiredSize: Int, measureSpec: Int): Int {
var result: Int var result: Int
val specMode = MeasureSpec.getMode(measureSpec) val specMode = MeasureSpec.getMode(measureSpec)
@@ -152,3 +155,9 @@ fun View.setOnContextClickListenerCompat(listener: View.OnLongClickListener) {
setOnContextClickListener(listener::onLongClick) setOnContextClickListener(listener::onLongClick)
} }
} }
val Toolbar.menuView: ActionMenuView?
get() {
menu // to call ensureMenu()
return children.firstNotNullOfOrNull { it as? ActionMenuView }
}

View File

@@ -1,12 +1,14 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.work.Data
import androidx.work.WorkInfo import androidx.work.WorkInfo
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkQuery import androidx.work.WorkQuery
import androidx.work.WorkRequest import androidx.work.WorkRequest
import androidx.work.await import androidx.work.await
import androidx.work.impl.WorkManagerImpl import androidx.work.impl.WorkManagerImpl
import androidx.work.impl.model.WorkSpec
import java.util.UUID import java.util.UUID
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@@ -69,5 +71,24 @@ suspend fun WorkManager.awaitUpdateWork(request: WorkRequest): WorkManager.Updat
return updateWork(request).await() return updateWork(request).await()
} }
@SuppressLint("RestrictedApi")
suspend fun WorkManager.getWorkSpec(id: UUID): WorkSpec? = suspendCoroutine { cont ->
workManagerImpl.workTaskExecutor.executeOnTaskThread {
try {
val spec = workManagerImpl.workDatabase.workSpecDao().getWorkSpec(id.toString())
cont.resume(spec)
} catch (e: Exception) {
cont.resumeWithException(e)
}
}
}
@SuppressLint("RestrictedApi")
suspend fun WorkManager.getWorkInputData(id: UUID): Data? = getWorkSpec(id)?.input
val Data.isEmpty: Boolean
get() = this == Data.EMPTY
private val WorkManager.workManagerImpl private val WorkManager.workManagerImpl
@SuppressLint("RestrictedApi") inline get() = this as WorkManagerImpl @SuppressLint("RestrictedApi") inline get() = this as WorkManagerImpl

View File

@@ -1,26 +0,0 @@
package org.koitharu.kotatsu.core.util.progress
import androidx.annotation.AnyThread
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.download.ui.worker.PausingHandle
class PausingProgressJob<P>(
job: Job,
progress: StateFlow<P>,
private val pausingHandle: PausingHandle,
) : ProgressJob<P>(job, progress) {
@get:AnyThread
val isPaused: Boolean
get() = pausingHandle.isPaused
@AnyThread
suspend fun awaitResumed() = pausingHandle.awaitResumed()
@AnyThread
fun pause() = pausingHandle.pause()
@AnyThread
fun resume() = pausingHandle.resume()
}

View File

@@ -1,16 +0,0 @@
package org.koitharu.kotatsu.core.util.progress
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
open class ProgressJob<P>(
private val job: Job,
private val progress: StateFlow<P>,
) : Job by job {
val progressValue: P
get() = progress.value
fun progressAsFlow(): Flow<P> = progress
}

View File

@@ -21,8 +21,7 @@ data class MangaDetails(
val branches: Set<String?> val branches: Set<String?>
get() = chapters.keys get() = chapters.keys
val allChapters: List<MangaChapter> val allChapters: List<MangaChapter> by lazy { mergeChapters() }
get() = manga.chapters.orEmpty()
val isLocal val isLocal
get() = manga.isLocal get() = manga.isLocal
@@ -40,4 +39,26 @@ data class MangaDetails(
description = description, description = description,
isLoaded = isLoaded, isLoaded = isLoaded,
) )
private fun mergeChapters(): List<MangaChapter> {
val chapters = manga.chapters
val localChapters = local?.manga?.chapters.orEmpty()
if (chapters.isNullOrEmpty()) {
return localChapters
}
val localMap = if (localChapters.isNotEmpty()) {
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
} else {
null
}
val result = ArrayList<MangaChapter>(chapters.size)
for (chapter in chapters) {
val local = localMap?.remove(chapter.id)
result += local ?: chapter
}
if (!localMap.isNullOrEmpty()) {
result.addAll(localMap.values)
}
return result
}
} }

View File

@@ -91,7 +91,7 @@ class MangaPrefetchService : CoroutineIntentService() {
val intent = Intent(context, MangaPrefetchService::class.java) val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_DETAILS intent.action = ACTION_PREFETCH_DETAILS
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga)) intent.putExtra(EXTRA_MANGA, ParcelableManga(manga))
context.startService(intent) tryStart(context, intent)
} }
fun prefetchPages(context: Context, chapter: MangaChapter) { fun prefetchPages(context: Context, chapter: MangaChapter) {
@@ -99,19 +99,14 @@ class MangaPrefetchService : CoroutineIntentService() {
val intent = Intent(context, MangaPrefetchService::class.java) val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_PAGES intent.action = ACTION_PREFETCH_PAGES
intent.putExtra(EXTRA_CHAPTER, ParcelableChapter(chapter)) intent.putExtra(EXTRA_CHAPTER, ParcelableChapter(chapter))
try { tryStart(context, intent)
context.startService(intent)
} catch (e: IllegalStateException) {
// probably app is in background
e.printStackTraceDebug()
}
} }
fun prefetchLast(context: Context) { fun prefetchLast(context: Context) {
if (!isPrefetchAvailable(context, null)) return if (!isPrefetchAvailable(context, null)) return
val intent = Intent(context, MangaPrefetchService::class.java) val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_LAST intent.action = ACTION_PREFETCH_LAST
context.startService(intent) tryStart(context, intent)
} }
private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean { private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean {
@@ -127,5 +122,14 @@ class MangaPrefetchService : CoroutineIntentService() {
) )
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled
} }
private fun tryStart(context: Context, intent: Intent) {
try {
context.startService(intent)
} catch (e: IllegalStateException) {
// probably app is in background
e.printStackTraceDebug()
}
}
} }
} }

View File

@@ -6,12 +6,14 @@ import android.view.View
import android.view.View.OnLayoutChangeListener import android.view.View.OnLayoutChangeListener
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import org.koitharu.kotatsu.core.ui.util.ActionModeListener import org.koitharu.kotatsu.core.ui.util.ActionModeListener
import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged
class ChaptersBottomSheetMediator( class ChaptersBottomSheetMediator(
private val behavior: BottomSheetBehavior<*>, private val behavior: BottomSheetBehavior<*>,
private val pager: ViewPager2,
) : OnBackPressedCallback(false), ) : OnBackPressedCallback(false),
ActionModeListener, ActionModeListener,
OnLayoutChangeListener, View.OnGenericMotionListener { OnLayoutChangeListener, View.OnGenericMotionListener {
@@ -74,6 +76,7 @@ class ChaptersBottomSheetMediator(
fun lock() { fun lock() {
lockCounter++ lockCounter++
behavior.isDraggable = lockCounter <= 0 behavior.isDraggable = lockCounter <= 0
pager.isUserInputEnabled = lockCounter <= 0
} }
fun unlock() { fun unlock() {
@@ -82,5 +85,6 @@ class ChaptersBottomSheetMediator(
lockCounter = 0 lockCounter = 0
} }
behavior.isDraggable = lockCounter <= 0 behavior.isDraggable = lockCounter <= 0
pager.isUserInputEnabled = lockCounter <= 0
} }
} }

View File

@@ -22,13 +22,14 @@ import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans import androidx.core.text.inSpans
import androidx.core.view.MenuHost
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
@@ -37,15 +38,19 @@ import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.os.AppShortcutManager 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.prefs.AppSettings
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.ui.util.ReversibleActionObserver
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
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.measureHeight import org.koitharu.kotatsu.core.util.ext.measureHeight
import org.koitharu.kotatsu.core.util.ext.menuView
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.recyclerView
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.core.util.ext.setNavigationIconSafe import org.koitharu.kotatsu.core.util.ext.setNavigationIconSafe
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
@@ -54,6 +59,7 @@ import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.pager.DetailsPagerAdapter
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -74,10 +80,18 @@ class DetailsActivity :
@Inject @Inject
lateinit var appShortcutManager: AppShortcutManager lateinit var appShortcutManager: AppShortcutManager
@Inject
lateinit var settings: AppSettings
private var buttonTip: WeakReference<ButtonTip>? = null private var buttonTip: WeakReference<ButtonTip>? = null
private val viewModel: DetailsViewModel by viewModels() private val viewModel: DetailsViewModel by viewModels()
private lateinit var chaptersMenuProvider: ChaptersMenuProvider
val secondaryMenuHost: MenuHost
get() = viewBinding.toolbarChapters ?: this
var bottomSheetMediator: ChaptersBottomSheetMediator? = null
private set
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -93,24 +107,22 @@ class DetailsActivity :
if (viewBinding.layoutBottom != null) { if (viewBinding.layoutBottom != null) {
val behavior = BottomSheetBehavior.from(checkNotNull(viewBinding.layoutBottom)) val behavior = BottomSheetBehavior.from(checkNotNull(viewBinding.layoutBottom))
val bsMediator = ChaptersBottomSheetMediator(behavior) val bsMediator = ChaptersBottomSheetMediator(behavior, viewBinding.pager)
actionModeDelegate.addListener(bsMediator) actionModeDelegate.addListener(bsMediator)
checkNotNull(viewBinding.layoutBsHeader).addOnLayoutChangeListener(bsMediator) checkNotNull(viewBinding.layoutBsHeader).addOnLayoutChangeListener(bsMediator)
onBackPressedDispatcher.addCallback(bsMediator) onBackPressedDispatcher.addCallback(bsMediator)
chaptersMenuProvider = ChaptersMenuProvider(viewModel, bsMediator) bottomSheetMediator = bsMediator
behavior.doOnExpansionsChanged(::onChaptersSheetStateChanged) behavior.doOnExpansionsChanged(::onChaptersSheetStateChanged)
viewBinding.toolbarChapters?.setNavigationOnClickListener { viewBinding.toolbarChapters?.setNavigationOnClickListener {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED behavior.state = BottomSheetBehavior.STATE_COLLAPSED
} }
viewBinding.toolbarChapters?.setOnGenericMotionListener(bsMediator) viewBinding.toolbarChapters?.setOnGenericMotionListener(bsMediator)
} else {
chaptersMenuProvider = ChaptersMenuProvider(viewModel, null)
addMenuProvider(chaptersMenuProvider)
} }
onBackPressedDispatcher.addCallback(chaptersMenuProvider) initPager()
viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated) viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated)
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved) viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onError.observeEvent( viewModel.onError.observeEvent(
this, this,
SnackbarErrorObserver( SnackbarErrorObserver(
@@ -124,19 +136,16 @@ class DetailsActivity :
}, },
), ),
) )
viewModel.onShowToast.observeEvent(this) { viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.containerDetails))
makeSnackbar(getString(it), Snackbar.LENGTH_SHORT).show()
}
viewModel.onShowTip.observeEvent(this) { showTip() } viewModel.onShowTip.observeEvent(this) { showTip() }
viewModel.historyInfo.observe(this, ::onHistoryChanged) viewModel.historyInfo.observe(this, ::onHistoryChanged)
viewModel.selectedBranch.observe(this) { viewModel.selectedBranch.observe(this) {
viewBinding.toolbarChapters?.subtitle = it viewBinding.toolbarChapters?.subtitle = it
viewBinding.textViewSubtitle?.textAndVisible = it viewBinding.textViewSubtitle?.textAndVisible = it
} }
viewModel.isChaptersReversed.observe( val chaptersMenuInvalidator = MenuInvalidator(viewBinding.toolbarChapters ?: this)
this, viewModel.isChaptersReversed.observe(this, chaptersMenuInvalidator)
MenuInvalidator(viewBinding.toolbarChapters ?: this), viewModel.isChaptersEmpty.observe(this, chaptersMenuInvalidator)
)
val menuInvalidator = MenuInvalidator(this) val menuInvalidator = MenuInvalidator(this)
viewModel.favouriteCategories.observe(this, menuInvalidator) viewModel.favouriteCategories.observe(this, menuInvalidator)
viewModel.remoteManga.observe(this, menuInvalidator) viewModel.remoteManga.observe(this, menuInvalidator)
@@ -153,7 +162,7 @@ class DetailsActivity :
DetailsMenuProvider( DetailsMenuProvider(
activity = this, activity = this,
viewModel = viewModel, viewModel = viewModel,
snackbarHost = viewBinding.containerChapters, snackbarHost = viewBinding.pager,
appShortcutManager = appShortcutManager, appShortcutManager = appShortcutManager,
), ),
) )
@@ -217,12 +226,11 @@ class DetailsActivity :
TransitionManager.beginDelayedTransition(toolbar, transition) TransitionManager.beginDelayedTransition(toolbar, transition)
} }
if (isExpanded) { if (isExpanded) {
toolbar.addMenuProvider(chaptersMenuProvider)
toolbar.setNavigationIconSafe(materialR.drawable.abc_ic_clear_material) toolbar.setNavigationIconSafe(materialR.drawable.abc_ic_clear_material)
} else { } else {
toolbar.removeMenuProvider(chaptersMenuProvider)
toolbar.navigationIcon = null toolbar.navigationIcon = null
} }
toolbar.menuView?.isVisible = isExpanded
viewBinding.buttonRead.isGone = isExpanded viewBinding.buttonRead.isGone = isExpanded
} }
@@ -293,6 +301,18 @@ class DetailsActivity :
viewBinding.textViewTitle?.text = text viewBinding.textViewTitle?.text = text
} }
private fun onNewChaptersChanged(count: Int) {
val tab = viewBinding.tabs.getTabAt(0) ?: return
if (count == 0) {
tab.removeBadge()
} else {
val badge = tab.orCreateBadge
badge.horizontalOffsetWithText = -resources.getDimensionPixelOffset(R.dimen.margin_small)
badge.number = count
badge.isVisible = true
}
}
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
@@ -326,9 +346,8 @@ class DetailsActivity :
val manga = viewModel.manga.value ?: return val manga = viewModel.manga.value ?: return
val chapterId = viewModel.historyInfo.value.history?.chapterId val chapterId = viewModel.historyInfo.value.history?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) { if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
val snackbar = Snackbar.make(viewBinding.containerDetails, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT)
makeSnackbar(getString(R.string.chapter_is_missing), Snackbar.LENGTH_SHORT) .show()
snackbar.show()
} else { } else {
startActivity( startActivity(
IntentBuilder(this) IntentBuilder(this)
@@ -343,6 +362,14 @@ class DetailsActivity :
} }
} }
private fun initPager() {
viewBinding.pager.recyclerView?.isNestedScrollingEnabled = false
val adapter = DetailsPagerAdapter(this)
viewBinding.pager.adapter = adapter
TabLayoutMediator(viewBinding.tabs, viewBinding.pager, adapter).attach()
viewBinding.pager.setCurrentItem(settings.defaultDetailsTab, false)
}
private fun showBottomSheet(isVisible: Boolean) { private fun showBottomSheet(isVisible: Boolean) {
val view = viewBinding.layoutBottom ?: return val view = viewBinding.layoutBottom ?: return
if (view.isVisible == isVisible) return if (view.isVisible == isVisible) return
@@ -353,17 +380,6 @@ class DetailsActivity :
view.isVisible = isVisible view.isVisible = isVisible
} }
private fun makeSnackbar(
text: CharSequence,
@BaseTransientBottomBar.Duration duration: Int,
): Snackbar {
val sb = Snackbar.make(viewBinding.containerDetails, text, duration)
if (viewBinding.layoutBottom?.isVisible == true) {
sb.anchorView = viewBinding.toolbarChapters
}
return sb
}
private class PrefetchObserver( private class PrefetchObserver(
private val context: Context, private val context: Context,
) : FlowCollector<List<ChapterListItem>?> { ) : FlowCollector<List<ChapterListItem>?> {

View File

@@ -10,8 +10,6 @@ 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.text.method.LinkMovementMethodCompat
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
@@ -23,13 +21,14 @@ 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
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksSheet import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksSheet
import org.koitharu.kotatsu.core.model.countChaptersByBranch import org.koitharu.kotatsu.core.model.countChaptersByBranch
import org.koitharu.kotatsu.core.model.iconResId
import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
@@ -40,7 +39,6 @@ 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
@@ -66,7 +64,6 @@ import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
@@ -74,7 +71,6 @@ 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 :
@@ -121,7 +117,7 @@ class DetailsFragment :
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged) viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
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) viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged)
} }
override fun onItemClick(item: Bookmark, view: View) { override fun onItemClick(item: Bookmark, view: View) {
@@ -181,28 +177,13 @@ class DetailsFragment :
ratingBar.isVisible = false ratingBar.isVisible = false
} }
when (manga.state) { infoLayout.textViewState.apply {
MangaState.FINISHED -> infoLayout.textViewState.apply { manga.state?.let { state ->
textAndVisible = resources.getString(R.string.state_finished) textAndVisible = resources.getString(state.titleResId)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_finished) drawableTop = ContextCompat.getDrawable(context, state.iconResId)
} ?: run {
isVisible = false
} }
MangaState.ONGOING -> infoLayout.textViewState.apply {
textAndVisible = resources.getString(R.string.state_ongoing)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_ongoing)
}
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
@@ -218,8 +199,7 @@ class DetailsFragment :
} }
} }
private fun onChaptersChanged(data: Pair<List<ChapterListItem>?, Int>) { private fun onChaptersChanged(chapters: List<ChapterListItem>?) {
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
@@ -227,19 +207,7 @@ class DetailsFragment :
val count = chapters.countChaptersByBranch() val count = chapters.countChaptersByBranch()
infoLayout.textViewChapters.isVisible = true infoLayout.textViewChapters.isVisible = true
val chaptersText = resources.getQuantityString(R.plurals.chapters, count, count) val chaptersText = resources.getQuantityString(R.plurals.chapters, count, count)
infoLayout.textViewChapters.text = if (newChapters == 0) { infoLayout.textViewChapters.text = chaptersText
chaptersText
} else {
buildSpannedString {
append(chaptersText)
append(' ')
color(infoLayout.textViewChapters.context.getThemeColor(materialR.attr.colorError)) {
append("(+")
append(newChapters.toString())
append(')')
}
}
}
} }
} }

View File

@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import okio.FileNotFoundException
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
@@ -30,6 +31,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow 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.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.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.computeSize
@@ -53,6 +55,7 @@ import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
@@ -79,7 +82,7 @@ class DetailsViewModel @Inject constructor(
private val mangaId = intent.mangaId private val mangaId = intent.mangaId
private var loadingJob: Job private var loadingJob: Job
val onShowToast = MutableEventFlow<Int>() val onActionDone = MutableEventFlow<ReversibleAction>()
val onShowTip = MutableEventFlow<Unit>() val onShowTip = MutableEventFlow<Unit>()
val onSelectChapter = MutableEventFlow<Long>() val onSelectChapter = MutableEventFlow<Long>()
val onDownloadStarted = MutableEventFlow<Unit>() val onDownloadStarted = MutableEventFlow<Unit>()
@@ -134,8 +137,14 @@ class DetailsViewModel @Inject constructor(
.map { it?.local } .map { it?.local }
.distinctUntilChanged() .distinctUntilChanged()
.map { local -> .map { local ->
local?.file?.computeSize() ?: 0L if (local != null) {
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(), 0) runCatchingCancellable {
local.file.computeSize()
}.getOrDefault(0L)
} else {
0L
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), 0L)
@Deprecated("") @Deprecated("")
val description = details val description = details
@@ -227,7 +236,7 @@ class DetailsViewModel @Inject constructor(
fun deleteLocal() { fun deleteLocal() {
val m = details.value?.local?.manga val m = details.value?.local?.manga
if (m == null) { if (m == null) {
onShowToast.call(R.string.file_not_found) errorEvent.call(FileNotFoundException())
return return
} }
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
@@ -239,7 +248,7 @@ class DetailsViewModel @Inject constructor(
fun removeBookmark(bookmark: Bookmark) { fun removeBookmark(bookmark: Bookmark) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
bookmarksRepository.removeBookmark(bookmark) bookmarksRepository.removeBookmark(bookmark)
onShowToast.call(R.string.bookmark_removed) onActionDone.call(ReversibleAction(R.string.bookmark_removed, null))
} }
} }

View File

@@ -1,39 +0,0 @@
package org.koitharu.kotatsu.details.ui.adapter
import android.graphics.Color
import android.text.Spannable
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import androidx.core.text.buildSpannedString
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding
import org.koitharu.kotatsu.details.ui.model.MangaBranch
fun branchAD(
clickListener: OnListItemClickListener<MangaBranch>,
) = adapterDelegateViewBinding<MangaBranch, MangaBranch, ItemCheckableNewBinding>(
{ inflater, parent -> ItemCheckableNewBinding.inflate(inflater, parent, false) },
) {
val clickAdapter = AdapterDelegateClickListenerAdapter(this, clickListener)
itemView.setOnClickListener(clickAdapter)
val counterColorSpan = ForegroundColorSpan(context.getThemeColor(android.R.attr.textColorSecondary, Color.LTGRAY))
val counterSizeSpan = RelativeSizeSpan(0.86f)
bind {
binding.root.text = buildSpannedString {
append(item.name ?: getString(R.string.system_default))
append(' ')
append(' ')
val start = length
append(item.count.toString())
setSpan(counterColorSpan, start, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
setSpan(counterSizeSpan, start, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
binding.root.isChecked = item.isSelected
}
}

View File

@@ -6,7 +6,6 @@ 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

View File

@@ -0,0 +1,32 @@
package org.koitharu.kotatsu.details.ui.pager
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.details.ui.pager.chapters.ChaptersFragment
import org.koitharu.kotatsu.details.ui.pager.pages.PagesFragment
class DetailsPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity),
TabLayoutMediator.TabConfigurationStrategy {
override fun getItemCount(): Int = 2
override fun createFragment(position: Int): Fragment = when (position) {
0 -> ChaptersFragment()
1 -> PagesFragment()
else -> throw IllegalArgumentException("Invalid position $position")
}
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
tab.setText(
when (position) {
0 -> R.string.chapters
1 -> R.string.pages
else -> 0
},
)
}
}

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.details.ui package org.koitharu.kotatsu.details.ui.pager.chapters
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
@@ -11,6 +10,7 @@ import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseFragment
@@ -19,8 +19,10 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.ChaptersMenuProvider
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
@@ -58,9 +60,6 @@ class ChaptersFragment :
checkNotNull(selectionController).attachToRecyclerView(this) checkNotNull(selectionController).attachToRecyclerView(this)
setHasFixedSize(true) setHasFixedSize(true)
adapter = chaptersAdapter adapter = chaptersAdapter
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
scrollIndicators = if (resources.getBoolean(R.bool.is_tablet)) 0 else View.SCROLL_INDICATOR_TOP
}
} }
viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged) viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged)
viewModel.chapters.observe(viewLifecycleOwner, this::onChaptersChanged) viewModel.chapters.observe(viewLifecycleOwner, this::onChaptersChanged)
@@ -70,6 +69,12 @@ class ChaptersFragment :
viewModel.onSelectChapter.observeEvent(viewLifecycleOwner) { viewModel.onSelectChapter.observeEvent(viewLifecycleOwner) {
selectionController?.onItemLongClick(it) selectionController?.onItemLongClick(it)
} }
val detailsActivity = activity as? DetailsActivity
if (detailsActivity != null) {
val menuProvider = ChaptersMenuProvider(viewModel, detailsActivity.bottomSheetMediator)
activity?.onBackPressedDispatcher?.addCallback(menuProvider)
detailsActivity.secondaryMenuHost.addMenuProvider(menuProvider, viewLifecycleOwner, Lifecycle.State.RESUMED)
}
} }
override fun onDestroyView() { override fun onDestroyView() {

View File

@@ -0,0 +1,189 @@
package org.koitharu.kotatsu.details.ui.pager.pages
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.Insets
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.BoundsScrollListener
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.databinding.FragmentPagesBinding
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter
import javax.inject.Inject
import kotlin.math.roundToInt
@AndroidEntryPoint
class PagesFragment :
BaseFragment<FragmentPagesBinding>(),
OnListItemClickListener<PageThumbnail> {
private val detailsViewModel by activityViewModels<DetailsViewModel>()
private val viewModel by viewModels<PagesViewModel>()
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var settings: AppSettings
private var thumbnailsAdapter: PageThumbnailAdapter? = null
private var spanResolver: MangaListSpanResolver? = null
private var scrollListener: ScrollListener? = null
private val spanSizeLookup = SpanSizeLookup()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
combine(
detailsViewModel.details,
detailsViewModel.history,
detailsViewModel.selectedBranch,
) { details, history, branch ->
if (details != null && (details.isLoaded || details.chapters.isNotEmpty())) {
PagesViewModel.State(details, history, branch)
} else {
null
}
}.observe(this, viewModel::updateState)
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentPagesBinding {
return FragmentPagesBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: FragmentPagesBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
spanResolver = MangaListSpanResolver(binding.root.resources)
thumbnailsAdapter = PageThumbnailAdapter(
coil = coil,
lifecycleOwner = viewLifecycleOwner,
clickListener = this@PagesFragment,
)
with(binding.recyclerView) {
addItemDecoration(TypedListSpacingDecoration(context, false))
adapter = thumbnailsAdapter
setHasFixedSize(true)
addOnLayoutChangeListener(spanResolver)
spanResolver?.setGridSize(settings.gridSize / 100f, this)
addOnScrollListener(ScrollListener().also { scrollListener = it })
(layoutManager as GridLayoutManager).let {
it.spanSizeLookup = spanSizeLookup
it.spanCount = checkNotNull(spanResolver).spanCount
}
}
detailsViewModel.isChaptersEmpty.observe(viewLifecycleOwner, ::onNoChaptersChanged)
viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged)
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) }
viewModel.isLoadingUp.observe(viewLifecycleOwner) { binding.progressBarTop.showOrHide(it) }
viewModel.isLoadingDown.observe(viewLifecycleOwner) { binding.progressBarBottom.showOrHide(it) }
}
override fun onDestroyView() {
spanResolver = null
scrollListener = null
thumbnailsAdapter = null
spanSizeLookup.invalidateCache()
super.onDestroyView()
}
override fun onWindowInsetsChanged(insets: Insets) = Unit
override fun onItemClick(item: PageThumbnail, view: View) {
val manga = detailsViewModel.manga.value ?: return
val state = ReaderState(item.page.chapterId, item.page.index, 0)
val intent = IntentBuilder(view.context).manga(manga).state(state).build()
startActivity(intent)
}
private suspend fun onThumbnailsChanged(list: List<ListModel>) {
val adapter = thumbnailsAdapter ?: return
if (adapter.itemCount == 0) {
var position = list.indexOfFirst { it is PageThumbnail && it.isCurrent }
if (position > 0) {
val spanCount = spanResolver?.spanCount ?: 0
val offset = if (position > spanCount + 1) {
(resources.getDimensionPixelSize(R.dimen.manga_list_details_item_height) * 0.6).roundToInt()
} else {
position = 0
0
}
val scrollCallback = RecyclerViewScrollCallback(requireViewBinding().recyclerView, position, offset)
adapter.emit(list)
scrollCallback.run()
} else {
adapter.emit(list)
}
} else {
adapter.emit(list)
}
spanSizeLookup.invalidateCache()
viewBinding?.recyclerView?.let {
scrollListener?.postInvalidate(it)
}
}
private fun onNoChaptersChanged(isNoChapters: Boolean) {
with(viewBinding ?: return) {
textViewHolder.isVisible = isNoChapters
recyclerView.isInvisible = isNoChapters
}
}
private inner class ScrollListener : BoundsScrollListener(3, 3) {
override fun onScrolledToStart(recyclerView: RecyclerView) {
viewModel.loadPrevChapter()
}
override fun onScrolledToEnd(recyclerView: RecyclerView) {
viewModel.loadNextChapter()
}
}
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {
init {
isSpanIndexCacheEnabled = true
isSpanGroupIndexCacheEnabled = true
}
override fun getSpanSize(position: Int): Int {
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1
return when (thumbnailsAdapter?.getItemViewType(position)) {
ListItemType.PAGE_THUMB.ordinal -> 1
else -> total
}
}
fun invalidateCache() {
invalidateSpanGroupIndexCache()
invalidateSpanIndexCache()
}
}
}

View File

@@ -0,0 +1,115 @@
package org.koitharu.kotatsu.details.ui.pager.pages
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.firstNotNull
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.domain.ChaptersLoader
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
import javax.inject.Inject
@HiltViewModel
class PagesViewModel @Inject constructor(
private val chaptersLoader: ChaptersLoader,
) : BaseViewModel() {
private var loadingJob: Job? = null
private var loadingPrevJob: Job? = null
private var loadingNextJob: Job? = null
private val state = MutableStateFlow<State?>(null)
val thumbnails = MutableStateFlow<List<ListModel>>(emptyList())
val isLoadingUp = MutableStateFlow(false)
val isLoadingDown = MutableStateFlow(false)
init {
loadingJob = launchLoadingJob(Dispatchers.Default) {
val firstState = state.firstNotNull()
doInit(firstState)
launchJob(Dispatchers.Default) {
state.collectLatest {
if (it != null) {
doInit(it)
}
}
}
}
}
fun updateState(newState: State?) {
if (newState != null) {
state.value = newState
}
}
fun loadPrevChapter() {
if (loadingJob?.isActive == true || loadingPrevJob?.isActive == true) {
return
}
loadingPrevJob = loadPrevNextChapter(isNext = false)
}
fun loadNextChapter() {
if (loadingJob?.isActive == true || loadingNextJob?.isActive == true) {
return
}
loadingNextJob = loadPrevNextChapter(isNext = true)
}
private suspend fun doInit(state: State) {
chaptersLoader.init(state.details)
val initialChapterId = state.history?.chapterId ?: state.details.allChapters.firstOrNull()?.id ?: return
if (!chaptersLoader.hasPages(initialChapterId)) {
chaptersLoader.loadSingleChapter(initialChapterId)
}
updateList(state.history)
}
private fun loadPrevNextChapter(isNext: Boolean): Job = launchJob(Dispatchers.Default) {
val indicator = if (isNext) isLoadingDown else isLoadingUp
indicator.value = true
try {
val currentState = state.firstNotNull()
val currentId = (if (isNext) chaptersLoader.last() else chaptersLoader.first()).chapterId
chaptersLoader.loadPrevNextChapter(currentState.details, currentId, isNext)
updateList(currentState.history)
} finally {
indicator.value = false
}
}
private fun updateList(history: MangaHistory?) {
val snapshot = chaptersLoader.snapshot()
val pages = buildList(snapshot.size + chaptersLoader.size + 2) {
var previousChapterId = 0L
for (page in snapshot) {
if (page.chapterId != previousChapterId) {
chaptersLoader.peekChapter(page.chapterId)?.let {
add(ListHeader(it.name))
}
previousChapterId = page.chapterId
}
this += PageThumbnail(
isCurrent = history?.let {
page.chapterId == it.chapterId && page.index == it.page
} ?: false,
page = page,
)
}
}
thumbnails.value = pages
}
data class State(
val details: MangaDetails,
val history: MangaHistory?,
val branch: String?
)
}

View File

@@ -4,7 +4,7 @@ import androidx.work.Data
import org.koitharu.kotatsu.history.data.PROGRESS_NONE import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date import java.time.Instant
data class DownloadState( data class DownloadState(
val manga: Manga, val manga: Manga,
@@ -53,7 +53,7 @@ data class DownloadState(
private const val DATA_PROGRESS = "progress" private const val DATA_PROGRESS = "progress"
private const val DATA_CHAPTERS = "chapter_cnt" 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" const val DATA_TIMESTAMP = "timestamp"
private const val DATA_ERROR = "error" private const val DATA_ERROR = "error"
private const val DATA_INDETERMINATE = "indeterminate" private const val DATA_INDETERMINATE = "indeterminate"
private const val DATA_PAUSED = "paused" private const val DATA_PAUSED = "paused"
@@ -72,7 +72,7 @@ data class DownloadState(
fun getEta(data: Data): Long = data.getLong(DATA_ETA, -1L) fun getEta(data: Data): Long = data.getLong(DATA_ETA, -1L)
fun getTimestamp(data: Data): Date = Date(data.getLong(DATA_TIMESTAMP, 0L)) fun getTimestamp(data: Data): Instant = Instant.ofEpochMilli(data.getLong(DATA_TIMESTAMP, 0L))
fun getDownloadedChapters(data: Data): Int = data.getInt(DATA_CHAPTERS, 0) fun getDownloadedChapters(data: Data): Int = data.getInt(DATA_CHAPTERS, 0)
} }

View File

@@ -1,23 +1,33 @@
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.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.work.WorkInfo import androidx.work.WorkInfo
import coil.ImageLoader import coil.ImageLoader
import coil.request.SuccessResult import coil.request.SuccessResult
import coil.util.CoilUtils import coil.util.CoilUtils
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.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.download.ui.list.chapters.DownloadChapter
import org.koitharu.kotatsu.download.ui.list.chapters.downloadChapterAD
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
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
@@ -30,14 +40,16 @@ 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) var chaptersJob: Job? = null
val clickListener = object : View.OnClickListener, View.OnLongClickListener { val clickListener = object : View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.button_cancel -> listener.onCancelClick(item) R.id.button_cancel -> listener.onCancelClick(item)
R.id.button_resume -> listener.onResumeClick(item) R.id.button_resume -> listener.onResumeClick(item, skip = false)
R.id.button_skip -> listener.onResumeClick(item, skip = true)
R.id.button_pause -> listener.onPauseClick(item) R.id.button_pause -> listener.onPauseClick(item)
R.id.imageView_expand -> listener.onExpandClick(item)
else -> listener.onItemClick(item, v) else -> listener.onItemClick(item, v)
} }
} }
@@ -46,31 +58,62 @@ fun downloadItemAD(
return listener.onItemLongClick(item, v) return listener.onItemLongClick(item, v)
} }
} }
val chaptersAdapter = BaseListAdapter<DownloadChapter>()
.addDelegate(ListItemType.CHAPTER, downloadChapterAD())
binding.recyclerViewChapters.addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL))
binding.recyclerViewChapters.adapter = chaptersAdapter
binding.buttonCancel.setOnClickListener(clickListener) binding.buttonCancel.setOnClickListener(clickListener)
binding.buttonPause.setOnClickListener(clickListener) binding.buttonPause.setOnClickListener(clickListener)
binding.buttonResume.setOnClickListener(clickListener) binding.buttonResume.setOnClickListener(clickListener)
binding.buttonSkip.setOnClickListener(clickListener)
binding.imageViewExpand.setOnClickListener(clickListener)
itemView.setOnClickListener(clickListener) itemView.setOnClickListener(clickListener)
itemView.setOnLongClickListener(clickListener) itemView.setOnLongClickListener(clickListener)
bind { payloads -> fun scrollToCurrentChapter() {
if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads && context.isAnimationsEnabled) { val rv = binding.recyclerViewChapters
TransitionManager.beginDelayedTransition(binding.constraintLayout) if (!rv.isVisible) {
return
} }
binding.textViewTitle.text = item.manga.title val chapters = chaptersAdapter.items
if (chapters.isEmpty()) {
return
}
val targetPos = item.chaptersDownloaded.coerceIn(chapters.indices)
(rv.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(targetPos, rv.height / 3)
}
bind { payloads ->
binding.textViewTitle.text = item.manga?.title ?: getString(R.string.unknown)
if ((CoilUtils.result(binding.imageViewCover) as? SuccessResult)?.memoryCacheKey != item.coverCacheKey) { if ((CoilUtils.result(binding.imageViewCover) as? SuccessResult)?.memoryCacheKey != item.coverCacheKey) {
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.apply { binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga?.coverUrl)?.apply {
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)
allowRgb565(true) allowRgb565(true)
transformations(TrimTransformation()) transformations(TrimTransformation())
memoryCacheKey(item.coverCacheKey) memoryCacheKey(item.coverCacheKey)
source(item.manga.source) source(item.manga?.source)
enqueueWith(coil) enqueueWith(coil)
} }
} }
// binding.textViewTitle.isChecked = item.isExpanded if (chaptersJob == null || payloads.isEmpty()) {
// binding.textViewTitle.drawableEnd = if (item.isExpandable) expandIcon else null chaptersJob?.cancel()
chaptersJob = lifecycleOwner.lifecycleScope.launch(start = CoroutineStart.UNDISPATCHED) {
item.chapters.collect { chapters ->
binding.imageViewExpand.isGone = chapters.isNullOrEmpty()
chaptersAdapter.emit(chapters)
scrollToCurrentChapter()
}
}
} else if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads) {
binding.recyclerViewChapters.post {
scrollToCurrentChapter()
}
}
binding.imageViewExpand.isChecked = item.isExpanded
binding.recyclerViewChapters.isVisible = item.isExpanded
when (item.workState) { when (item.workState) {
WorkInfo.State.ENQUEUED, WorkInfo.State.ENQUEUED,
WorkInfo.State.BLOCKED -> { WorkInfo.State.BLOCKED -> {
@@ -82,6 +125,7 @@ fun downloadItemAD(
binding.textViewDetails.isVisible = false binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = true binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false binding.buttonResume.isVisible = false
binding.buttonSkip.isVisible = false
binding.buttonPause.isVisible = false binding.buttonPause.isVisible = false
} }
@@ -96,9 +140,10 @@ fun downloadItemAD(
binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty()) binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty())
binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1)) binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1))
binding.textViewPercent.isVisible = true binding.textViewPercent.isVisible = true
binding.textViewDetails.textAndVisible = item.getEtaString() binding.textViewDetails.textAndVisible = if (item.isPaused) item.error else item.getEtaString()
binding.buttonCancel.isVisible = true binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = item.isPaused binding.buttonResume.isVisible = item.isPaused
binding.buttonSkip.isVisible = item.isPaused && item.error != null
binding.buttonPause.isVisible = item.canPause binding.buttonPause.isVisible = item.canPause
} }
@@ -120,6 +165,7 @@ fun downloadItemAD(
} }
binding.buttonCancel.isVisible = false binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false binding.buttonResume.isVisible = false
binding.buttonSkip.isVisible = false
binding.buttonPause.isVisible = false binding.buttonPause.isVisible = false
} }
@@ -132,6 +178,7 @@ fun downloadItemAD(
binding.textViewDetails.textAndVisible = item.error binding.textViewDetails.textAndVisible = item.error
binding.buttonCancel.isVisible = false binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false binding.buttonResume.isVisible = false
binding.buttonSkip.isVisible = false
binding.buttonPause.isVisible = false binding.buttonPause.isVisible = false
} }
@@ -144,6 +191,7 @@ fun downloadItemAD(
binding.textViewDetails.isVisible = false binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false binding.buttonResume.isVisible = false
binding.buttonSkip.isVisible = false
binding.buttonPause.isVisible = false binding.buttonPause.isVisible = false
} }
} }

View File

@@ -8,5 +8,7 @@ interface DownloadItemListener : OnListItemClickListener<DownloadItemModel> {
fun onPauseClick(item: DownloadItemModel) fun onPauseClick(item: DownloadItemModel)
fun onResumeClick(item: DownloadItemModel) fun onResumeClick(item: DownloadItemModel, skip: Boolean)
fun onExpandClick(item: DownloadItemModel)
} }

View File

@@ -3,10 +3,12 @@ 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 coil.memory.MemoryCache
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
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.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date import java.time.Instant
import java.util.UUID import java.util.UUID
data class DownloadItemModel( data class DownloadItemModel(
@@ -14,17 +16,18 @@ data class DownloadItemModel(
val workState: WorkInfo.State, val workState: WorkInfo.State,
val isIndeterminate: Boolean, val isIndeterminate: Boolean,
val isPaused: Boolean, val isPaused: Boolean,
val manga: Manga, val manga: Manga?,
val error: String?, val error: String?,
val max: Int, val max: Int,
val progress: Int, val progress: Int,
val eta: Long, val eta: Long,
val timestamp: Date, val timestamp: Instant,
val chaptersDownloaded: Int, val chaptersDownloaded: Int,
val isExpanded: Boolean, val isExpanded: Boolean,
val chapters: StateFlow<List<DownloadChapter>?>,
) : ListModel, Comparable<DownloadItemModel> { ) : ListModel, Comparable<DownloadItemModel> {
val coverCacheKey = MemoryCache.Key(manga.coverUrl, mapOf("dl" to "1")) val coverCacheKey = MemoryCache.Key(manga?.coverUrl.orEmpty(), 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
@@ -38,9 +41,6 @@ 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,

View File

@@ -84,17 +84,19 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
if (selectionController.onItemClick(item.id.mostSignificantBits)) { if (selectionController.onItemClick(item.id.mostSignificantBits)) {
return return
} }
if (item.isExpandable) { startActivity(DetailsActivity.newIntent(view.context, item.manga ?: return))
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 {
return selectionController.onItemLongClick(item.id.mostSignificantBits) return selectionController.onItemLongClick(item.id.mostSignificantBits)
} }
override fun onExpandClick(item: DownloadItemModel) {
if (!selectionController.onItemClick(item.id.mostSignificantBits)) {
viewModel.expandCollapse(item)
}
}
override fun onCancelClick(item: DownloadItemModel) { override fun onCancelClick(item: DownloadItemModel) {
viewModel.cancel(item.id) viewModel.cancel(item.id)
} }
@@ -103,8 +105,8 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
sendBroadcast(PausingReceiver.getPauseIntent(this, item.id)) sendBroadcast(PausingReceiver.getPauseIntent(this, item.id))
} }
override fun onResumeClick(item: DownloadItemModel) { override fun onResumeClick(item: DownloadItemModel, skip: Boolean) {
sendBroadcast(PausingReceiver.getResumeIntent(this, item.id)) sendBroadcast(PausingReceiver.getResumeIntent(this, item.id, skip))
} }
override fun onSelectionChanged(controller: ListSelectionController, count: Int) { override fun onSelectionChanged(controller: ListSelectionController, count: Int) {

View File

@@ -1,16 +1,19 @@
package org.koitharu.kotatsu.download.ui.list package org.koitharu.kotatsu.download.ui.list
import androidx.collection.ArrayMap
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import androidx.collection.getOrElse import androidx.collection.getOrElse
import androidx.collection.set import androidx.collection.set
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
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.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -25,21 +28,24 @@ 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
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.daysDiff import org.koitharu.kotatsu.core.util.ext.isEmpty
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel 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.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga
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 org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Date
import java.util.LinkedList import java.util.LinkedList
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -47,11 +53,15 @@ 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, private val mangaRepositoryFactory: MangaRepository.Factory,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
private val localMangaRepository: LocalMangaRepository,
) : BaseViewModel() { ) : BaseViewModel() {
private val mangaCache = LongSparseArray<Manga>() private val mangaCache = LongSparseArray<Manga>()
private val cacheMutex = Mutex() private val cacheMutex = Mutex()
private val expanded = MutableStateFlow(emptySet<UUID>()) private val expanded = MutableStateFlow(emptySet<UUID>())
private val chaptersCache = ArrayMap<UUID, StateFlow<List<DownloadChapter>?>>()
private val works = combine( private val works = combine(
workScheduler.observeWorks(), workScheduler.observeWorks(),
expanded, expanded,
@@ -131,7 +141,7 @@ class DownloadsViewModel @Inject constructor(
var isResumed = false var isResumed = false
for (work in snapshot) { for (work in snapshot) {
if (work.workState == WorkInfo.State.RUNNING && work.isPaused) { if (work.workState == WorkInfo.State.RUNNING && work.isPaused) {
workScheduler.resume(work.id) workScheduler.resume(work.id, skipError = false)
isResumed = true isResumed = true
} }
} }
@@ -144,7 +154,7 @@ class DownloadsViewModel @Inject constructor(
val snapshot = works.value ?: return val snapshot = works.value ?: return
for (work in snapshot) { for (work in snapshot) {
if (work.id.mostSignificantBits in ids) { if (work.id.mostSignificantBits in ids) {
workScheduler.resume(work.id) workScheduler.resume(work.id, skipError = false)
} }
} }
onActionDone.call(ReversibleAction(R.string.downloads_resumed, null)) onActionDone.call(ReversibleAction(R.string.downloads_resumed, null))
@@ -213,7 +223,7 @@ class DownloadsViewModel @Inject constructor(
WorkInfo.State.ENQUEUED -> queued += item WorkInfo.State.ENQUEUED -> queued += item
else -> { else -> {
val date = timeAgo(item.timestamp) val date = calculateTimeAgo(item.timestamp)
if (prevDate != date) { if (prevDate != date) {
destination += ListHeader(date) destination += ListHeader(date)
} }
@@ -234,10 +244,18 @@ class DownloadsViewModel @Inject constructor(
} }
private suspend fun WorkInfo.toUiModel(isExpanded: Boolean): DownloadItemModel? { private suspend fun WorkInfo.toUiModel(isExpanded: Boolean): DownloadItemModel? {
val workData = if (outputData == Data.EMPTY) progress else outputData val workData = outputData.takeUnless { it.isEmpty }
?: progress.takeUnless { it.isEmpty }
?: workScheduler.getInputData(id)
?: return null
val mangaId = DownloadState.getMangaId(workData) val mangaId = DownloadState.getMangaId(workData)
if (mangaId == 0L) return null if (mangaId == 0L) return null
val manga = getManga(mangaId) ?: return null val manga = getManga(mangaId) ?: return null
val chapters = synchronized(chaptersCache) {
chaptersCache.getOrPut(id) {
observeChapters(manga, id)
}
}
return DownloadItemModel( return DownloadItemModel(
id = id, id = id,
workState = state, workState = state,
@@ -251,22 +269,10 @@ class DownloadsViewModel @Inject constructor(
timestamp = DownloadState.getTimestamp(workData), timestamp = DownloadState.getTimestamp(workData),
chaptersDownloaded = DownloadState.getDownloadedChapters(workData), chaptersDownloaded = DownloadState.getDownloadedChapters(workData),
isExpanded = isExpanded, isExpanded = isExpanded,
chapters = chapters,
) )
} }
private fun timeAgo(date: Date): DateTimeAgo {
val diff = (System.currentTimeMillis() - date.time).coerceAtLeast(0L)
val diffMinutes = TimeUnit.MILLISECONDS.toMinutes(diff).toInt()
val diffDays = -date.daysDiff(System.currentTimeMillis())
return when {
diffMinutes < 3 -> DateTimeAgo.JustNow
diffDays < 1 -> DateTimeAgo.Today
diffDays == 1 -> DateTimeAgo.Yesterday
diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays)
else -> DateTimeAgo.Absolute(date)
}
}
private fun emptyStateList() = listOf( private fun emptyStateList() = listOf(
EmptyState( EmptyState(
icon = R.drawable.ic_empty_common, icon = R.drawable.ic_empty_common,
@@ -282,16 +288,42 @@ class DownloadsViewModel @Inject constructor(
} }
return cacheMutex.withLock { return cacheMutex.withLock {
mangaCache.getOrElse(mangaId) { mangaCache.getOrElse(mangaId) {
mangaDataRepository.findMangaById(mangaId)?.let { mangaDataRepository.findMangaById(mangaId)?.also {
tryLoad(it) ?: it
}?.also {
mangaCache[mangaId] = it mangaCache[mangaId] = it
} ?: return null } ?: return null
} }
} }
} }
private fun observeChapters(manga: Manga, workId: UUID): StateFlow<List<DownloadChapter>?> = flow {
val chapterIds = workScheduler.getInputChaptersIds(workId)?.toSet()
val chapters = (tryLoad(manga) ?: manga).chapters ?: return@flow
suspend fun mapChapters(): List<DownloadChapter> {
val size = chapterIds?.size ?: chapters.size
val localChapters =
localMangaRepository.findSavedManga(manga)?.manga?.chapters?.mapToSet { it.id }.orEmpty()
return chapters.mapNotNullTo(ArrayList(size)) {
if (chapterIds == null || it.id in chapterIds) {
DownloadChapter(
number = it.number,
name = it.name,
isDownloaded = it.id in localChapters,
)
} else {
null
}
}
}
emit(mapChapters())
localStorageChanges.collect {
if (it?.manga?.id == manga.id) {
emit(mapChapters())
}
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private suspend fun tryLoad(manga: Manga) = runCatchingCancellable { private suspend fun tryLoad(manga: Manga) = runCatchingCancellable {
(mangaRepositoryFactory.create(manga.source) as RemoteMangaRepository).peekDetails(manga) (mangaRepositoryFactory.create(manga.source) as RemoteMangaRepository).getDetails(manga)
}.getOrNull() }.getOrNull()
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.download.ui.list.chapters package org.koitharu.kotatsu.download.ui.list.chapters
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
data class DownloadChapter( data class DownloadChapter(
@@ -11,4 +12,12 @@ data class DownloadChapter(
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {
return other is DownloadChapter && other.name == name return other is DownloadChapter && other.name == name
} }
override fun getChangePayload(previousState: ListModel): Any? {
return if (previousState is DownloadChapter && previousState.name == name && previousState.number == number) {
ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED
} else {
super.getChangePayload(previousState)
}
}
} }

View File

@@ -82,7 +82,15 @@ class DownloadNotificationFactory @AssistedInject constructor(
NotificationCompat.Action( NotificationCompat.Action(
R.drawable.ic_action_resume, R.drawable.ic_action_resume,
context.getString(R.string.resume), context.getString(R.string.resume),
PausingReceiver.createResumePendingIntent(context, uuid), PausingReceiver.createResumePendingIntent(context, uuid, skipError = false),
)
}
private val actionSkip by lazy {
NotificationCompat.Action(
R.drawable.ic_action_skip,
context.getString(R.string.skip),
PausingReceiver.createResumePendingIntent(context, uuid, skipError = true),
) )
} }
@@ -163,6 +171,9 @@ class DownloadNotificationFactory @AssistedInject constructor(
builder.setSmallIcon(R.drawable.ic_stat_paused) builder.setSmallIcon(R.drawable.ic_stat_paused)
builder.addAction(actionCancel) builder.addAction(actionCancel)
builder.addAction(actionResume) builder.addAction(actionResume)
if (state.error != null) {
builder.addAction(actionSkip)
}
} }
state.error != null -> { // error, final state state.error != null -> { // error, final state

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.download.ui.worker
import kotlinx.coroutines.delay
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaSource
class DownloadSlowdownDispatcher(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val defaultDelay: Long,
) {
private val timeMap = HashMap<MangaSource, Long>()
suspend fun delay(source: MangaSource) {
val repo = mangaRepositoryFactory.create(source) as? RemoteMangaRepository ?: return
if (!repo.isSlowdownEnabled()) {
return
}
val lastRequest = synchronized(timeMap) {
val res = timeMap[source] ?: 0L
timeMap[source] = System.currentTimeMillis()
res
}
if (lastRequest != 0L) {
delay(lastRequest + defaultDelay - System.currentTimeMillis())
}
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.download.ui.worker package org.koitharu.kotatsu.download.ui.worker
import android.annotation.SuppressLint
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
@@ -7,7 +8,6 @@ import android.os.Build
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.lifecycle.asFlow
import androidx.work.BackoffPolicy import androidx.work.BackoffPolicy
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
@@ -29,6 +29,10 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -52,6 +56,8 @@ import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.deleteWork import org.koitharu.kotatsu.core.util.ext.deleteWork
import org.koitharu.kotatsu.core.util.ext.deleteWorks import org.koitharu.kotatsu.core.util.ext.deleteWorks
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getWorkInputData
import org.koitharu.kotatsu.core.util.ext.getWorkSpec
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
@@ -60,6 +66,7 @@ import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.TempFileFilter
import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
@@ -72,6 +79,7 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject import javax.inject.Inject
@HiltWorker @HiltWorker
@@ -82,7 +90,6 @@ class DownloadWorker @AssistedInject constructor(
private val cache: PagesCache, private val cache: PagesCache,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val mangaDataRepository: MangaDataRepository, private val mangaDataRepository: MangaDataRepository,
private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
notificationFactoryFactory: DownloadNotificationFactory.Factory, notificationFactoryFactory: DownloadNotificationFactory.Factory,
@@ -90,16 +97,15 @@ class DownloadWorker @AssistedInject constructor(
private val notificationFactory = notificationFactoryFactory.create(params.id) private val notificationFactory = notificationFactoryFactory.create(params.id)
private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val slowdownDispatcher = DownloadSlowdownDispatcher(mangaRepositoryFactory, SLOWDOWN_DELAY)
@Volatile @Volatile
private var lastPublishedState: DownloadState? = null private var lastPublishedState: DownloadState? = null
private val currentState: DownloadState private val currentState: DownloadState
get() = checkNotNull(lastPublishedState) get() = checkNotNull(lastPublishedState)
private val pausingHandle = PausingHandle()
private val timeLeftEstimator = TimeLeftEstimator() private val timeLeftEstimator = TimeLeftEstimator()
private val notificationThrottler = Throttler(400) private val notificationThrottler = Throttler(400)
private val pausingReceiver = PausingReceiver(params.id, pausingHandle)
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
setForeground(getForegroundInfo()) setForeground(getForegroundInfo())
@@ -110,14 +116,18 @@ class DownloadWorker @AssistedInject constructor(
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() } val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
val downloadedIds = getDoneChapters(manga) val downloadedIds = getDoneChapters(manga)
return try { return try {
downloadMangaImpl(manga, chaptersIds, downloadedIds) withContext(PausingHandle()) {
downloadMangaImpl(manga, chaptersIds, downloadedIds)
}
Result.success(currentState.toWorkData()) Result.success(currentState.toWorkData())
} catch (e: CancellationException) { } catch (e: CancellationException) {
withContext(NonCancellable) { withContext(NonCancellable) {
val notification = notificationFactory.create(currentState.copy(isStopped = true)) val notification = notificationFactory.create(currentState.copy(isStopped = true))
notificationManager.notify(id.hashCode(), notification) notificationManager.notify(id.hashCode(), notification)
} }
throw e Result.failure(
currentState.copy(eta = -1L).toWorkData(),
)
} catch (e: IOException) { } catch (e: IOException) {
e.printStackTraceDebug() e.printStackTraceDebug()
Result.retry() Result.retry()
@@ -154,6 +164,7 @@ class DownloadWorker @AssistedInject constructor(
) { ) {
var manga = subject var manga = subject
val chaptersToSkip = excludedIds.toMutableSet() val chaptersToSkip = excludedIds.toMutableSet()
val pausingReceiver = PausingReceiver(id, PausingHandle.current())
withMangaLock(manga) { withMangaLock(manga) {
ContextCompat.registerReceiver( ContextCompat.registerReceiver(
applicationContext, applicationContext,
@@ -163,7 +174,6 @@ class DownloadWorker @AssistedInject constructor(
) )
val destination = localMangaRepository.getOutputDir(manga) val destination = localMangaRepository.getOutputDir(manga)
checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) } checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) }
val tempFileName = "${manga.id}_$id.tmp"
var output: LocalMangaOutput? = null var output: LocalMangaOutput? = null
try { try {
if (manga.source == MangaSource.LOCAL) { if (manga.source == MangaSource.LOCAL) {
@@ -175,45 +185,57 @@ class DownloadWorker @AssistedInject constructor(
output = LocalMangaOutput.getOrCreate(destination, mangaDetails) output = LocalMangaOutput.getOrCreate(destination, mangaDetails)
val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl } val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl }
if (coverUrl.isNotEmpty()) { if (coverUrl.isNotEmpty()) {
downloadFile(coverUrl, destination, tempFileName, repo.source).let { file -> downloadFile(coverUrl, destination, repo.source).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
file.deleteAwait()
} }
} }
val chapters = getChapters(mangaDetails, includedIds) val chapters = getChapters(mangaDetails, includedIds)
for ((chapterIndex, chapter) in chapters.withIndex()) { for ((chapterIndex, chapter) in chapters.withIndex()) {
checkIsPaused()
if (chaptersToSkip.remove(chapter.id)) { if (chaptersToSkip.remove(chapter.id)) {
publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1)) publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1))
continue continue
} }
val pages = runFailsafe(pausingHandle) { val pages = runFailsafe {
repo.getPages(chapter) repo.getPages(chapter)
} } ?: continue
for ((pageIndex, page) in pages.withIndex()) { val pageCounter = AtomicInteger(0)
runFailsafe(pausingHandle) { channelFlow {
val url = repo.getPageUrl(page) val semaphore = Semaphore(MAX_PAGES_PARALLELISM)
val file = cache.get(url) for ((pageIndex, page) in pages.withIndex()) {
?: downloadFile(url, destination, tempFileName, repo.source) checkIsPaused()
output.addPage( launch {
chapter = chapter, semaphore.withPermit {
file = file, runFailsafe {
pageNumber = pageIndex, val url = repo.getPageUrl(page)
ext = MimeTypeMap.getFileExtensionFromUrl(url), val file = cache.get(url)
) ?: downloadFile(url, destination, repo.source)
output.addPage(
chapter = chapter,
file = file,
pageNumber = pageIndex,
ext = MimeTypeMap.getFileExtensionFromUrl(url),
)
if (file.extension == "tmp") {
file.deleteAwait()
}
}
send(pageIndex)
}
}
} }
}.collect {
publishState( publishState(
currentState.copy( currentState.copy(
totalChapters = chapters.size, totalChapters = chapters.size,
currentChapter = chapterIndex, currentChapter = chapterIndex,
totalPages = pages.size, totalPages = pages.size,
currentPage = pageIndex, currentPage = pageCounter.incrementAndGet(),
isIndeterminate = false, isIndeterminate = false,
eta = timeLeftEstimator.getEta(), eta = timeLeftEstimator.getEta(),
), ),
) )
if (settings.isDownloadsSlowdownEnabled) {
delay(SLOWDOWN_DELAY)
}
} }
if (output.flushChapter(chapter)) { if (output.flushChapter(chapter)) {
runCatchingCancellable { runCatchingCancellable {
@@ -238,21 +260,18 @@ class DownloadWorker @AssistedInject constructor(
applicationContext.unregisterReceiver(pausingReceiver) applicationContext.unregisterReceiver(pausingReceiver)
output?.closeQuietly() output?.closeQuietly()
output?.cleanup() output?.cleanup()
File(destination, tempFileName).deleteAwait() destination.listFiles(TempFileFilter())?.forEach {
it.deleteAwait()
}
} }
} }
} }
} }
private suspend fun <R> runFailsafe( private suspend fun <R> runFailsafe(
pausingHandle: PausingHandle,
block: suspend () -> R, block: suspend () -> R,
): R { ): R? {
if (pausingHandle.isPaused) { checkIsPaused()
publishState(currentState.copy(isPaused = true, eta = -1L))
pausingHandle.awaitResumed()
publishState(currentState.copy(isPaused = false))
}
var countDown = MAX_FAILSAFE_ATTEMPTS var countDown = MAX_FAILSAFE_ATTEMPTS
failsafe@ while (true) { failsafe@ while (true) {
try { try {
@@ -267,9 +286,16 @@ class DownloadWorker @AssistedInject constructor(
), ),
) )
countDown = MAX_FAILSAFE_ATTEMPTS countDown = MAX_FAILSAFE_ATTEMPTS
val pausingHandle = PausingHandle.current()
pausingHandle.pause() pausingHandle.pause()
pausingHandle.awaitResumed() try {
publishState(currentState.copy(isPaused = false, error = null)) pausingHandle.awaitResumed()
if (pausingHandle.skipCurrentError()) {
return null
}
} finally {
publishState(currentState.copy(isPaused = false, error = null))
}
} else { } else {
countDown-- countDown--
val retryDelay = if (e is TooManyRequestExceptions) { val retryDelay = if (e is TooManyRequestExceptions) {
@@ -283,10 +309,21 @@ class DownloadWorker @AssistedInject constructor(
} }
} }
private suspend fun checkIsPaused() {
val pausingHandle = PausingHandle.current()
if (pausingHandle.isPaused) {
publishState(currentState.copy(isPaused = true, eta = -1L))
try {
pausingHandle.awaitResumed()
} finally {
publishState(currentState.copy(isPaused = false))
}
}
}
private suspend fun downloadFile( private suspend fun downloadFile(
url: String, url: String,
destination: File, destination: File,
tempFileName: String,
source: MangaSource, source: MangaSource,
): File { ): File {
val request = Request.Builder() val request = Request.Builder()
@@ -296,13 +333,19 @@ class DownloadWorker @AssistedInject constructor(
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE) .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
.get() .get()
.build() .build()
slowdownDispatcher.delay(source)
val call = okHttp.newCall(request) val call = okHttp.newCall(request)
val file = File(destination, tempFileName) val file = File(destination, UUID.randomUUID().toString() + ".tmp")
val response = call.clone().await() try {
checkNotNull(response.body).use { body -> val response = call.clone().await()
file.sink(append = false).buffer().use { checkNotNull(response.body).use { body ->
it.writeAllCancellable(body.source()) file.sink(append = false).buffer().use {
it.writeAllCancellable(body.source())
}
} }
} catch (e: CancellationException) {
file.delete()
throw e
} }
return file return file
} }
@@ -385,8 +428,20 @@ class DownloadWorker @AssistedInject constructor(
} }
fun observeWorks(): Flow<List<WorkInfo>> = workManager fun observeWorks(): Flow<List<WorkInfo>> = workManager
.getWorkInfosByTagLiveData(TAG) .getWorkInfosByTagFlow(TAG)
.asFlow()
@SuppressLint("RestrictedApi")
suspend fun getInputData(id: UUID): Data? {
val spec = workManager.getWorkSpec(id) ?: return null
return Data.Builder()
.putAll(spec.input)
.putLong(DownloadState.DATA_TIMESTAMP, spec.scheduleRequestedAt)
.build()
}
suspend fun getInputChaptersIds(workId: UUID): LongArray? {
return workManager.getWorkInputData(workId)?.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
}
suspend fun cancel(id: UUID) { suspend fun cancel(id: UUID) {
workManager.cancelWorkById(id).await() workManager.cancelWorkById(id).await()
@@ -401,8 +456,8 @@ class DownloadWorker @AssistedInject constructor(
context.sendBroadcast(intent) context.sendBroadcast(intent)
} }
fun resume(id: UUID) { fun resume(id: UUID, skipError: Boolean) {
val intent = PausingReceiver.getResumeIntent(context, id) val intent = PausingReceiver.getResumeIntent(context, id, skipError)
context.sendBroadcast(intent) context.sendBroadcast(intent)
} }
@@ -463,8 +518,9 @@ class DownloadWorker @AssistedInject constructor(
private companion object { private companion object {
const val MAX_FAILSAFE_ATTEMPTS = 2 const val MAX_FAILSAFE_ATTEMPTS = 2
const val MAX_PAGES_PARALLELISM = 4
const val DOWNLOAD_ERROR_DELAY = 500L const val DOWNLOAD_ERROR_DELAY = 500L
const val SLOWDOWN_DELAY = 100L const val SLOWDOWN_DELAY = 200L
const val MANGA_ID = "manga_id" const val MANGA_ID = "manga_id"
const val CHAPTERS_IDS = "chapters" const val CHAPTERS_IDS = "chapters"
const val TAG = "download" const val TAG = "download"

View File

@@ -1,13 +1,16 @@
package org.koitharu.kotatsu.download.ui.worker package org.koitharu.kotatsu.download.ui.worker
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
class PausingHandle { class PausingHandle : AbstractCoroutineContextElement(PausingHandle) {
private val paused = MutableStateFlow(false) private val paused = MutableStateFlow(false)
private val isSkipError = MutableStateFlow(false)
@get:AnyThread @get:AnyThread
val isPaused: Boolean val isPaused: Boolean
@@ -15,7 +18,7 @@ class PausingHandle {
@AnyThread @AnyThread
suspend fun awaitResumed() { suspend fun awaitResumed() {
paused.filter { !it }.first() paused.first { !it }
} }
@AnyThread @AnyThread
@@ -24,7 +27,23 @@ class PausingHandle {
} }
@AnyThread @AnyThread
fun resume() { fun resume(skipError: Boolean) {
isSkipError.value = skipError
paused.value = false paused.value = false
} }
suspend fun yield() {
if (paused.value) {
paused.first { !it }
}
}
fun skipCurrentError(): Boolean = isSkipError.compareAndSet(expect = true, update = false)
companion object : CoroutineContext.Key<PausingHandle> {
suspend fun current() = checkNotNull(currentCoroutineContext()[this]) {
"PausingHandle not found in current context"
}
}
} }

View File

@@ -21,7 +21,8 @@ class PausingReceiver(
return return
} }
when (intent.action) { when (intent.action) {
ACTION_RESUME -> pausingHandle.resume() ACTION_RESUME -> pausingHandle.resume(skipError = false)
ACTION_SKIP -> pausingHandle.resume(skipError = true)
ACTION_PAUSE -> pausingHandle.pause() ACTION_PAUSE -> pausingHandle.pause()
} }
} }
@@ -30,12 +31,14 @@ class PausingReceiver(
private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE" private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE"
private const val ACTION_RESUME = "org.koitharu.kotatsu.download.RESUME" private const val ACTION_RESUME = "org.koitharu.kotatsu.download.RESUME"
private const val ACTION_SKIP = "org.koitharu.kotatsu.download.SKIP"
private const val EXTRA_UUID = "uuid" private const val EXTRA_UUID = "uuid"
private const val SCHEME = "workuid" private const val SCHEME = "workuid"
fun createIntentFilter(id: UUID) = IntentFilter().apply { fun createIntentFilter(id: UUID) = IntentFilter().apply {
addAction(ACTION_PAUSE) addAction(ACTION_PAUSE)
addAction(ACTION_RESUME) addAction(ACTION_RESUME)
addAction(ACTION_SKIP)
addDataScheme(SCHEME) addDataScheme(SCHEME)
addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB) addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB)
} }
@@ -45,8 +48,9 @@ class PausingReceiver(
.setPackage(context.packageName) .setPackage(context.packageName)
.putExtra(EXTRA_UUID, id.toString()) .putExtra(EXTRA_UUID, id.toString())
fun getResumeIntent(context: Context, id: UUID) = Intent(ACTION_RESUME) fun getResumeIntent(context: Context, id: UUID, skipError: Boolean) = Intent(
.setData(Uri.parse("$SCHEME://$id")) if (skipError) ACTION_SKIP else ACTION_RESUME,
).setData(Uri.parse("$SCHEME://$id"))
.setPackage(context.packageName) .setPackage(context.packageName)
.putExtra(EXTRA_UUID, id.toString()) .putExtra(EXTRA_UUID, id.toString())
@@ -58,12 +62,13 @@ class PausingReceiver(
false, false,
) )
fun createResumePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast( fun createResumePendingIntent(context: Context, id: UUID, skipError: Boolean) =
context, PendingIntentCompat.getBroadcast(
0, context,
getResumeIntent(context, id), 0,
0, getResumeIntent(context, id, skipError),
false, 0,
) false,
)
} }
} }

View File

@@ -76,12 +76,9 @@ class ExploreRepository @Inject constructor(
} }
val list = repository.getList( val list = repository.getList(
offset = 0, offset = 0,
filter = MangaListFilter.Advanced( filter = MangaListFilter.Advanced.Builder(order)
sortOrder = order, .tags(setOfNotNull(tag))
tags = setOfNotNull(tag), .build(),
locale = null,
states = emptySet(),
),
).asArrayList() ).asArrayList()
if (settings.isSuggestionsExcludeNsfw) { if (settings.isSuggestionsExcludeNsfw) {
list.removeAll { it.isNsfw } list.removeAll { it.isNsfw }

View File

@@ -160,7 +160,7 @@ class ExploreFragment :
override fun onRetryClick(error: Throwable) = Unit override fun onRetryClick(error: Throwable) = Unit
override fun onEmptyActionClick() { override fun onEmptyActionClick() {
startActivity(SettingsActivity.newManageSourcesIntent(context ?: return)) startActivity(Intent(context ?: return, SourcesCatalogActivity::class.java))
} }
private fun onOpenManga(manga: Manga) { private fun onOpenManga(manga: Manga) {

View File

@@ -21,7 +21,6 @@ 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
@@ -30,7 +29,6 @@ import org.koitharu.kotatsu.list.ui.model.EmptyHint
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel 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.list.ui.model.TipModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -56,8 +54,6 @@ class ExploreViewModel @Inject constructor(
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>()
@@ -129,31 +125,25 @@ class ExploreViewModel @Inject constructor(
randomLoading: Boolean, randomLoading: Boolean,
newSources: Set<MangaSource>, newSources: Set<MangaSource>,
): List<ListModel> { ): List<ListModel> {
val result = ArrayList<ListModel>(sources.size + 4) val result = ArrayList<ListModel>(sources.size + 3)
result += ExploreButtons(randomLoading) result += ExploreButtons(randomLoading)
if (recommendation != null) { if (recommendation != null) {
result += ListHeader(R.string.suggestions) result += ListHeader(R.string.suggestions)
result += RecommendationsItem(recommendation) result += RecommendationsItem(recommendation)
} }
if (sources.isNotEmpty()) { if (sources.isNotEmpty()) {
result += ListHeader(R.string.remote_sources, R.string.catalog) result += ListHeader(
if (newSources.isNotEmpty()) { textRes = R.string.remote_sources,
result += TipModel( buttonTextRes = R.string.catalog,
key = TIP_NEW_SOURCES, badge = if (newSources.isNotEmpty()) "" else null,
title = R.string.new_sources_text, )
text = R.string.new_sources_text,
icon = R.drawable.ic_explore_normal,
primaryButtonText = R.string.manage,
secondaryButtonText = R.string.discard,
)
}
sources.mapTo(result) { MangaSourceItem(it, isGrid) } sources.mapTo(result) { MangaSourceItem(it, isGrid) }
} else { } else {
result += EmptyHint( result += EmptyHint(
icon = R.drawable.ic_empty_common, icon = R.drawable.ic_empty_common,
textPrimary = R.string.no_manga_sources, textPrimary = R.string.no_manga_sources,
textSecondary = R.string.no_manga_sources_text, textSecondary = R.string.no_manga_sources_text,
actionStringRes = R.string.manage, actionStringRes = R.string.catalog,
) )
} }
return result return result

View File

@@ -8,6 +8,7 @@ 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.model.getSummary
import org.koitharu.kotatsu.core.model.getTitle
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
@@ -112,7 +113,7 @@ fun exploreSourceListItemAD(
binding.root.setOnContextClickListenerCompat(eventListener) binding.root.setOnContextClickListenerCompat(eventListener)
bind { bind {
binding.textViewTitle.text = item.source.title binding.textViewTitle.text = item.source.getTitle(context)
binding.textViewSubtitle.text = item.source.getSummary(context) 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 {
@@ -147,7 +148,7 @@ fun exploreSourceGridItemAD(
binding.root.setOnContextClickListenerCompat(eventListener) binding.root.setOnContextClickListenerCompat(eventListener)
bind { bind {
binding.textViewTitle.text = item.source.title binding.textViewTitle.text = item.source.getTitle(context)
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Large, item.source.name) val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Large, item.source.name)
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run { binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
fallback(fallbackIcon) fallback(fallbackIcon)

View File

@@ -4,14 +4,14 @@ 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.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import java.util.Date import java.time.Instant
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 = ListSortOrder(order, ListSortOrder.NEWEST), order = ListSortOrder(order, ListSortOrder.NEWEST),
createdAt = Date(createdAt), createdAt = Instant.ofEpochMilli(createdAt),
isTrackingEnabled = track, isTrackingEnabled = track,
isVisibleInLibrary = isVisibleInLibrary, isVisibleInLibrary = isVisibleInLibrary,
) )

View File

@@ -46,6 +46,10 @@ abstract class FavouritesDao {
) )
abstract suspend fun findAll(offset: Int, limit: Int): List<FavouriteManga> abstract suspend fun findAll(offset: Int, limit: Int): List<FavouriteManga>
@Transaction
@Query("SELECT * FROM favourites WHERE deleted_at = 0 ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
abstract suspend fun findAllRaw(offset: Int, limit: Int): List<FavouriteManga>
@Transaction @Transaction
@Query( @Query(
"SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " + "SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " +

View File

@@ -142,7 +142,11 @@ class FavouriteCategoriesActivity :
} }
val fromPos = viewHolder.bindingAdapterPosition val fromPos = viewHolder.bindingAdapterPosition
val toPos = target.bindingAdapterPosition val toPos = target.bindingAdapterPosition
return fromPos != toPos && fromPos != RecyclerView.NO_POSITION && toPos != RecyclerView.NO_POSITION if (fromPos == toPos || fromPos == RecyclerView.NO_POSITION || toPos == RecyclerView.NO_POSITION) {
return false
}
adapter.reorderItems(fromPos, toPos)
return true
} }
override fun canDropOver( override fun canDropOver(
@@ -151,25 +155,16 @@ class FavouriteCategoriesActivity :
target: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder,
): Boolean = current.itemViewType == target.itemViewType ): Boolean = current.itemViewType == target.itemViewType
override fun onMoved(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
fromPos: Int,
target: RecyclerView.ViewHolder,
toPos: Int,
x: Int,
y: Int,
) {
super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y)
viewModel.reorderCategories(fromPos, toPos)
}
override fun isLongPressDragEnabled(): Boolean = false override fun isLongPressDragEnabled(): Boolean = false
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
super.onSelectedChanged(viewHolder, actionState) super.onSelectedChanged(viewHolder, actionState)
viewBinding.recyclerView.isNestedScrollingEnabled = viewBinding.recyclerView.isNestedScrollingEnabled = actionState == ItemTouchHelper.ACTION_STATE_IDLE
actionState == ItemTouchHelper.ACTION_STATE_IDLE }
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
super.clearView(recyclerView, viewHolder)
viewModel.saveOrder(adapter.items ?: return)
} }
} }

View File

@@ -1,13 +1,14 @@
package org.koitharu.kotatsu.favourites.ui.categories package org.koitharu.kotatsu.favourites.ui.categories
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.yield import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -19,7 +20,6 @@ import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel 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.util.move
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -30,17 +30,9 @@ class FavouritesCategoriesViewModel @Inject constructor(
private var commitJob: Job? = null private var commitJob: Job? = null
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState)) val content = repository.observeCategoriesWithCovers()
.map { it.toUiList() }
init { .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
launchJob(Dispatchers.Default) {
repository.observeCategoriesWithCovers()
.collectLatest {
commitJob?.join()
updateContent(it)
}
}
}
fun deleteCategories(ids: Set<Long>) { fun deleteCategories(ids: Set<Long>) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
@@ -54,11 +46,17 @@ class FavouritesCategoriesViewModel @Inject constructor(
fun isEmpty(): Boolean = content.value.none { it is CategoryListModel } fun isEmpty(): Boolean = content.value.none { it is CategoryListModel }
fun reorderCategories(oldPos: Int, newPos: Int) { fun saveOrder(snapshot: List<ListModel>) {
val snapshot = content.requireValue().toMutableList() val prevJob = commitJob
snapshot.move(oldPos, newPos) commitJob = launchJob {
content.value = snapshot prevJob?.cancelAndJoin()
commit(snapshot) val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) {
(it as? CategoryListModel)?.category?.id
}
if (ids.isNotEmpty()) {
repository.reorderCategories(ids)
}
}
} }
fun setIsVisible(ids: Set<Long>, isVisible: Boolean) { fun setIsVisible(ids: Set<Long>, isVisible: Boolean) {
@@ -76,36 +74,21 @@ class FavouritesCategoriesViewModel @Inject constructor(
} }
} }
private fun commit(snapshot: List<ListModel>) { private fun Map<FavouriteCategory, List<Cover>>.toUiList(): List<ListModel> = map { (category, covers) ->
val prevJob = commitJob CategoryListModel(
commitJob = launchJob { mangaCount = covers.size,
prevJob?.cancelAndJoin() covers = covers.take(3),
delay(500) category = category,
val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) { isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources,
(it as? CategoryListModel)?.category?.id )
} }.ifEmpty {
repository.reorderCategories(ids) listOf(
yield() EmptyState(
} icon = R.drawable.ic_empty_favourites,
} textPrimary = R.string.text_empty_holder_primary,
textSecondary = R.string.empty_favourite_categories,
private fun updateContent(categories: Map<FavouriteCategory, List<Cover>>) { actionStringRes = 0,
content.value = categories.map { (category, covers) -> ),
CategoryListModel( )
mangaCount = covers.size,
covers = covers.take(3),
category = category,
isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources,
)
}.ifEmpty {
listOf(
EmptyState(
icon = R.drawable.ic_empty_favourites,
textPrimary = R.string.text_empty_holder_primary,
textSecondary = R.string.empty_favourite_categories,
actionStringRes = 0,
),
)
}
} }
} }

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.favourites.ui.categories.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.ReorderableListAdapter
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
@@ -15,7 +15,7 @@ class CategoriesAdapter(
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
onItemClickListener: FavouriteCategoriesListListener, onItemClickListener: FavouriteCategoriesListListener,
listListener: ListStateHolderListener, listListener: ListStateHolderListener,
) : BaseListAdapter<ListModel>() { ) : ReorderableListAdapter<ListModel>() {
init { init {
addDelegate(ListItemType.CATEGORY_LARGE, categoryAD(coil, lifecycleOwner, onItemClickListener)) addDelegate(ListItemType.CATEGORY_LARGE, categoryAD(coil, lifecycleOwner, onItemClickListener))

View File

@@ -65,10 +65,7 @@ fun categoryAD(
binding.imageViewEdit.setOnClickListener(eventListener) binding.imageViewEdit.setOnClickListener(eventListener)
binding.imageViewHandle.setOnTouchListener(eventListener) binding.imageViewHandle.setOnTouchListener(eventListener)
bind { payloads -> bind {
if (payloads.isNotEmpty()) {
return@bind
}
binding.textViewTitle.text = item.category.title binding.textViewTitle.text = item.category.title
binding.textViewSubtitle.text = if (item.mangaCount == 0) { binding.textViewSubtitle.text = if (item.mangaCount == 0) {
getString(R.string.empty) getString(R.string.empty)

View File

@@ -15,13 +15,13 @@ fun categoriesHeaderAD() = adapterDelegateViewBinding<CategoriesHeaderItem, List
val onClickListener = View.OnClickListener { v -> val onClickListener = View.OnClickListener { v ->
val intent = when (v.id) { val intent = when (v.id) {
R.id.button_create -> FavouritesCategoryEditActivity.newIntent(v.context) R.id.chip_create -> FavouritesCategoryEditActivity.newIntent(v.context)
R.id.button_manage -> FavouriteCategoriesActivity.newIntent(v.context) R.id.chip_manage -> FavouriteCategoriesActivity.newIntent(v.context)
else -> return@OnClickListener else -> return@OnClickListener
} }
v.context.startActivity(intent) v.context.startActivity(intent)
} }
binding.buttonCreate.setOnClickListener(onClickListener) binding.chipCreate.setOnClickListener(onClickListener)
binding.buttonManage.setOnClickListener(onClickListener) binding.chipManage.setOnClickListener(onClickListener)
} }

View File

@@ -20,7 +20,7 @@ fun mangaCategoryAD(
} }
bind { payloads -> bind { payloads ->
binding.checkableImageView.setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED !in payloads) binding.checkableImageView.setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads)
binding.textViewTitle.text = item.category.title binding.textViewTitle.text = item.category.title
binding.imageViewTracker.isVisible = item.category.isTrackingEnabled && item.isTrackerEnabled binding.imageViewTracker.isVisible = item.category.isTrackingEnabled && item.isTrackerEnabled
binding.imageViewVisible.isVisible = item.category.isVisibleInLibrary binding.imageViewVisible.isVisible = item.category.isVisibleInLibrary

View File

@@ -7,6 +7,7 @@ import android.view.View
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
@@ -73,6 +74,18 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
true true
} }
R.id.action_mark_current -> {
MaterialAlertDialogBuilder(context ?: return false)
.setTitle(item.title)
.setMessage(R.string.mark_as_completed_prompt)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.markAsRead(selectedItems)
mode.finish()
}.show()
true
}
else -> super.onActionItemClicked(controller, mode, item) else -> super.onActionItemClicked(controller, mode, item)
} }
} }

View File

@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
import org.koitharu.kotatsu.history.domain.MarkAsReadUseCase
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
@@ -28,6 +29,7 @@ import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -35,11 +37,13 @@ class FavouritesListViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val repository: FavouritesRepository, private val repository: FavouritesRepository,
private val listExtraProvider: ListExtraProvider, private val listExtraProvider: ListExtraProvider,
private val markAsReadUseCase: MarkAsReadUseCase,
settings: AppSettings, settings: AppSettings,
downloadScheduler: DownloadWorker.Scheduler, downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler) { ) : MangaListViewModel(settings, downloadScheduler) {
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
private val refreshTrigger = MutableStateFlow(Any())
override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_FAVORITES) { favoritesListMode } override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_FAVORITES) { favoritesListMode }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.favoritesListMode) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.favoritesListMode)
@@ -59,7 +63,8 @@ class FavouritesListViewModel @Inject constructor(
repository.observeAll(categoryId) repository.observeAll(categoryId)
}, },
listMode, listMode,
) { list, mode -> refreshTrigger,
) { list, mode, _ ->
when { when {
list.isEmpty() -> listOf( list.isEmpty() -> listOf(
EmptyState( EmptyState(
@@ -80,10 +85,19 @@ class FavouritesListViewModel @Inject constructor(
emit(listOf(it.toErrorState(canRetry = false))) emit(listOf(it.toErrorState(canRetry = false)))
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
override fun onRefresh() = Unit override fun onRefresh() {
refreshTrigger.value = Any()
}
override fun onRetry() = Unit override fun onRetry() = Unit
fun markAsRead(items: Set<Manga>) {
launchLoadingJob(Dispatchers.Default) {
markAsReadUseCase(items)
onRefresh()
}
}
fun removeFromFavourites(ids: Set<Long>) { fun removeFromFavourites(ids: Set<Long>) {
if (ids.isEmpty()) { if (ids.isEmpty()) {
return return

View File

@@ -1,41 +0,0 @@
package org.koitharu.kotatsu.filter.ui
import android.content.Context
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.filter.ui.model.FilterItem
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
class FilterAdapter(
listener: OnFilterChangedListener,
listListener: ListListener<ListModel>,
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
init {
addDelegate(ListItemType.FILTER_SORT, filterSortDelegate(listener))
addDelegate(ListItemType.FILTER_TAG, filterTagDelegate(listener))
addDelegate(ListItemType.FILTER_TAG_MULTI, filterTagMultipleDelegate(listener))
addDelegate(ListItemType.FILTER_STATE, filterStateDelegate(listener))
addDelegate(ListItemType.HEADER, listHeaderAD(listener))
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
addDelegate(ListItemType.FOOTER_ERROR, filterErrorDelegate())
differ.addListListener(listListener)
}
override fun getSectionText(context: Context, position: Int): CharSequence? {
val list = items
for (i in (0..position).reversed()) {
val item = list.getOrNull(i) ?: continue
if (item is FilterItem.Tag) {
return item.tag.title.firstOrNull()?.toString()
}
}
return null
}
}

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