Compare commits

..

104 Commits

Author SHA1 Message Date
Koitharu
597abdb20c Update parsers and libraries 2024-08-17 11:47:56 +03:00
Koitharu
809e7d8701 Ability to check proxy connection 2024-08-12 18:22:05 +03:00
Koitharu
0015c5704a Fix ignoring external sources in global search 2024-08-12 17:30:21 +03:00
Koitharu
a7ff1610eb Fix crashes 2024-08-12 17:17:02 +03:00
Koitharu
22c402fc5e Update parers 2024-08-12 16:51:03 +03:00
Koitharu
6e92d46a63 Update parsers 2024-08-03 15:02:31 +03:00
Koitharu
66ed926ea8 Merge remote-tracking branch 'weblate/devel' into devel 2024-08-03 13:36:12 +03:00
Koitharu
b7741ce2af Allow to use biometric unlock manually (closes #999) 2024-08-03 13:35:28 +03:00
vianh
1a17324d26 Fix reader state not being restored 2024-08-03 12:47:57 +03:00
gekka
4044936481 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (664 of 664 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
Oğuz Ersen
1efe86421a Translated using Weblate (Turkish)
Currently translated at 100.0% (664 of 664 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
gallegonovato
34dd080f6c Translated using Weblate (Spanish)
Currently translated at 100.0% (664 of 664 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
Scrambled777
f4838afab0 Translated using Weblate (Hindi)
Currently translated at 100.0% (664 of 664 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
Anon
b207eebe56 Translated using Weblate (Serbian)
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
Draken
4f454ab438 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
weedyy
1ecf416113 Translated using Weblate (Arabic)
Currently translated at 99.8% (662 of 663 strings)

Co-authored-by: weedyy <huzskywalker@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
TheOneWhoCares
94670a03ff Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: TheOneWhoCares <266nre4gw@mozmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
Макар Разин
e92f165677 Translated using Weblate (Russian)
Currently translated at 100.0% (663 of 663 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (663 of 663 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-08-03 12:47:46 +03:00
gekka
4a03137a25 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (664 of 664 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (664 of 664 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
Oğuz Ersen
7e6e1fb6de Translated using Weblate (Turkish)
Currently translated at 100.0% (664 of 664 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
gallegonovato
f477797823 Translated using Weblate (Spanish)
Currently translated at 100.0% (664 of 664 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
Scrambled777
125b6740a6 Translated using Weblate (Hindi)
Currently translated at 100.0% (664 of 664 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
Anon
1618a11955 Translated using Weblate (Serbian)
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
Draken
966d6e2383 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
weedyy
2f33a135fc Translated using Weblate (Arabic)
Currently translated at 99.8% (662 of 663 strings)

Co-authored-by: weedyy <huzskywalker@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
TheOneWhoCares
207ea492d5 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: TheOneWhoCares <266nre4gw@mozmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
Макар Разин
250d5432a0 Translated using Weblate (Russian)
Currently translated at 100.0% (663 of 663 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (663 of 663 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-08-03 11:47:41 +02:00
Koitharu
9768758ecc Optimize external plugin cursor 2024-08-02 12:27:42 +03:00
Koitharu
20852dbd12 Fix query plugin source capabilities 2024-08-01 21:01:40 +03:00
Koitharu
2bc632474d Update parsers 2024-07-30 09:34:36 +03:00
gekka
78fd754d91 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-07-30 09:28:04 +03:00
Oğuz Ersen
bfa0045f1d Translated using Weblate (Turkish)
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-07-30 09:28:04 +03:00
gallegonovato
97e2d58750 Translated using Weblate (Spanish)
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-07-30 09:28:04 +03:00
maryush
ff668931ba Translated using Weblate (Polish)
Currently translated at 100.0% (662 of 662 strings)

Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-07-30 09:28:04 +03:00
weedyy
1c0149afc9 Translated using Weblate (Arabic)
Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Arabic)

Currently translated at 99.3% (658 of 662 strings)

Co-authored-by: weedyy <huzskywalker@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-07-30 09:28:04 +03:00
Draken
12ee3ef497 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/vi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-07-30 09:28:04 +03:00
Koitharu
ae2e38acac Trim description 2024-07-28 14:55:25 +03:00
Koitharu
f25050bce8 Support for manga sources from external APKs 2024-07-28 14:50:41 +03:00
Koitharu
830d500a68 Update dependencies 2024-07-28 07:31:45 +03:00
Anon
960e5d9d29 Translated using Weblate (Serbian)
Currently translated at 100.0% (662 of 662 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-07-27 12:20:41 +03:00
Yoshi Nizar
75b9f27761 Translated using Weblate (Italian)
Currently translated at 99.5% (659 of 662 strings)

Co-authored-by: Yoshi Nizar <canalefinto@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2024-07-27 12:20:41 +03:00
Draken
67af210f07 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/vi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-07-27 12:20:41 +03:00
Koitharu
06cdcac4df Update parsers 2024-07-27 12:13:45 +03:00
Koitharu
10dc1d10ed Dynamic source settings 2024-07-24 15:12:08 +03:00
Koitharu
43c65bf95b Fix global search 2024-07-24 11:40:55 +03:00
Koitharu
cb4ee2dcca Fix manga repository instantiation 2024-07-24 09:43:14 +03:00
Koitharu
bc64a96cc0 Translated using Weblate (Russian)
Currently translated at 100.0% (662 of 662 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
Joe
23dab16afc Translated using Weblate (Belarusian)
Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Joe <happenstance@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/be/
Translation: Kotatsu/plurals
2024-07-23 11:59:13 +03:00
Hosted Weblate
8755106fd2 Update translation files
Updated by "Remove blank strings" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
maryush
b2c6c95dbd Translated using Weblate (Polish)
Currently translated at 100.0% (658 of 658 strings)

Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
Lorenzo Stella
20d5fcd54d Translated using Weblate (Italian)
Currently translated at 98.0% (645 of 658 strings)

Co-authored-by: Lorenzo Stella <lorenzo.stella.1408@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
Draken
0d09233b28 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (658 of 658 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (658 of 658 strings)

Translated using Weblate (Vietnamese)

Currently translated at 99.8% (657 of 658 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
weedyy
1f2700de38 Translated using Weblate (Arabic)
Currently translated at 100.0% (658 of 658 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (658 of 658 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (658 of 658 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Arabic)

Currently translated at 88.7% (584 of 658 strings)

Co-authored-by: Hushhush <huzskywalker@gmail.com>
Co-authored-by: weedyy <huzskywalker@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-07-23 11:59:13 +03:00
Ahmed seif al-nasr
d7ebdfbf5a Translated using Weblate (Arabic)
Currently translated at 80.5% (530 of 658 strings)

Co-authored-by: Ahmed seif al-nasr <ahmdsyfalnsr2@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
weedyy
14b70a78ab Translated using Weblate (Arabic)
Currently translated at 78.5% (517 of 658 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Hushhush <huzskywalker@gmail.com>
Co-authored-by: weedyy <huzskywalker@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-07-23 11:59:13 +03:00
LinCie
dd41af8b8e Translated using Weblate (Indonesian)
Currently translated at 98.1% (646 of 658 strings)

Co-authored-by: LinCie <aldiofernanda@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
Eduardo
5b19d61069 Translated using Weblate (Portuguese (Brazil))
Currently translated at 98.6% (649 of 658 strings)

Co-authored-by: Eduardo <edu200399lim@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
Ahmed seif al-nasr
be3e028f5c Translated using Weblate (Arabic)
Currently translated at 70.2% (462 of 658 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Ahmed seif al-nasr <ahmdsyfalnsr2@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-07-23 11:59:13 +03:00
gekka
d231436eb0 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (658 of 658 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (658 of 658 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (658 of 658 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
Infy's Tagalog Translations
4c6276d3f6 Translated using Weblate (Filipino)
Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (658 of 658 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
Scrambled777
583c00d2b7 Translated using Weblate (Hindi)
Currently translated at 100.0% (658 of 658 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
Oğuz Ersen
060ded3915 Translated using Weblate (Turkish)
Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (658 of 658 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
gallegonovato
8482a8746f Translated using Weblate (Spanish)
Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (658 of 658 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
Макар Разин
dc12c0e770 Translated using Weblate (Belarusian)
Currently translated at 100.0% (658 of 658 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
Koitharu
6338e89507 Improve covers caching 2024-07-23 11:37:26 +03:00
Koitharu
0f97d29f6a Fix CloudFlare activity crash (close #982) 2024-07-23 10:53:38 +03:00
Koitharu
686f746070 Update parsers 2024-07-23 10:32:38 +03:00
Koitharu
5363719643 Show reverse progress and chapters in lists #904 2024-07-22 18:31:04 +03:00
Koitharu
607785dcd4 Refactor manga list model mapping 2024-07-22 15:02:01 +03:00
Koitharu
c14d39c456 Fix rtl paddings 2024-07-21 07:26:28 +03:00
Koitharu
2c9220090a Fix pinned sources order 2024-07-21 06:31:47 +03:00
Koitharu
b17ef8b6ff Fix sources catalog 2024-07-20 12:27:20 +03:00
Koitharu
6ac96747cf Update dependencies and targetSdk 2024-07-20 12:15:57 +03:00
Koitharu
92c8a13f96 Migrate to LongSet in selection controller 2024-07-15 19:36:11 +03:00
Koitharu
6d07c335de Show sources pinned icons 2024-07-15 17:10:31 +03:00
Koitharu
eba1679761 Animated source placeholder 2024-07-15 16:16:46 +03:00
Koitharu
05b05be0bd Fix dynamic sources 2024-07-15 15:46:48 +03:00
Koitharu
287861f5d7 Merge branch 'devel' into feature/dynamic_sources 2024-07-15 15:15:06 +03:00
Koitharu
4102c4a0ae Show last error in tracker log 2024-07-13 06:16:26 +03:00
Koitharu
d8fa0e33f1 Update parsers 2024-07-07 12:29:08 +03:00
Koitharu
97bc638f5f Translated using Weblate (Ukrainian)
Currently translated at 100.0% (658 of 658 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (658 of 658 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
2024-07-07 12:05:34 +03:00
Scrambled777
064c0ae425 Translated using Weblate (Hindi)
Currently translated at 100.0% (651 of 651 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-07-07 12:05:34 +03:00
Jesús Hernández santillan
ae7aa52177 Added translation using Weblate (Abkhazian)
Co-authored-by: Jesús Hernández santillan <jesusguibel122@gmail.com>
2024-07-07 12:05:34 +03:00
gallegonovato
6edda72d61 Translated using Weblate (Spanish)
Currently translated at 100.0% (651 of 651 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (650 of 650 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-07-07 12:05:34 +03:00
Akhil Raj
2f58f32bdd Translated using Weblate (Malayalam)
Currently translated at 2.6% (17 of 648 strings)

Co-authored-by: Akhil Raj <akhilakae07@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ml/
Translation: Kotatsu/Strings
2024-07-07 12:05:34 +03:00
Eno
0b821db046 Translated using Weblate (Arabic)
Currently translated at 60.4% (392 of 648 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Eno <msa39716@zbock.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-07-07 12:05:34 +03:00
Tawsif
36472998ee Translated using Weblate (Bengali)
Currently translated at 24.5% (159 of 648 strings)

Co-authored-by: Tawsif <tawsif7492@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/bn/
Translation: Kotatsu/Strings
2024-07-07 12:05:34 +03:00
Abay Emes
c2e7325876 Translated using Weblate (Kazakh)
Currently translated at 84.4% (547 of 648 strings)

Co-authored-by: Abay Emes <abayemes@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/kk/
Translation: Kotatsu/Strings
2024-07-07 12:05:34 +03:00
Zhafran Aziz
28a4a3849c Translated using Weblate (Indonesian)
Currently translated at 99.2% (643 of 648 strings)

Co-authored-by: Zhafran Aziz <aanmts70@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-07-07 12:05:34 +03:00
Biagio Ricci
6e9c934912 Translated using Weblate (Italian)
Currently translated at 94.5% (613 of 648 strings)

Co-authored-by: Biagio Ricci <biagior00@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2024-07-07 12:05:34 +03:00
Макар Разин
675ef0e629 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (648 of 648 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (648 of 648 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (648 of 648 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
2024-07-07 12:05:34 +03:00
Koitharu
484914b2dc Fix detection webtoon mode for local manga 2024-07-07 11:54:24 +03:00
Koitharu
ee85ef50f4 Use image proxy for downloading #897 2024-07-07 11:50:08 +03:00
Koitharu
dcee5542c5 Add recent sources to search suggestions 2024-07-07 11:38:35 +03:00
Koitharu
9b3ce4d849 Ability to pin manga sources (close #830, close #531) 2024-07-07 11:15:44 +03:00
Koitharu
5ab7e586f3 Option to sort manga sources by last used #947 2024-07-07 10:18:18 +03:00
Koitharu
9f5d4ed52c Refactor details title expansion 2024-07-07 09:38:34 +03:00
Koitharu
c3ca734005 Update reader state on chapter switch 2024-07-07 09:05:01 +03:00
Koitharu
a158a488f2 Fix error if manga has no chapters 2024-07-07 09:05:01 +03:00
Mac135135
6048cb917e Add functionality to expand manga title on click 2024-07-07 09:04:40 +03:00
Koitharu
81aac0d431 Pages crop feature #326 #919 2024-07-06 19:25:08 +03:00
Koitharu
dfb50fbddc Add image server option to reader config sheet 2024-07-06 14:21:46 +03:00
Koitharu
1f03e0a84b Update parsers and add image server option support 2024-07-06 12:47:01 +03:00
Koitharu
77e393ae48 Pages crop proof-of-concept 2024-06-29 15:56:32 +03:00
197 changed files with 3566 additions and 1156 deletions

1
.gitignore vendored
View File

@@ -25,3 +25,4 @@
.externalNativeBuild
.cxx
/.idea/deviceManager.xml
/.kotlin/

1
.idea/.gitignore generated vendored
View File

@@ -2,3 +2,4 @@
/shelf/
/workspace.xml
/migrations.xml
/runConfigurations.xml

View File

@@ -15,9 +15,9 @@ android {
defaultConfig {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 34
versionCode = 650
versionName = '7.2.1'
targetSdk = 35
versionCode = 660
versionName = '7.4.3'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
@@ -82,23 +82,23 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:f923acc5a7') {
implementation('com.github.KotatsuApp:kotatsu-parsers:98cbee11b9') {
exclude group: 'org.json', module: 'json'
}
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.10'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0-RC.2'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.activity:activity-ktx:1.9.0'
implementation 'androidx.fragment:fragment-ktx:1.8.0'
implementation 'androidx.transition:transition-ktx:1.5.0'
implementation 'androidx.collection:collection-ktx:1.4.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.2'
implementation 'androidx.lifecycle:lifecycle-service:2.8.2'
implementation 'androidx.lifecycle:lifecycle-process:2.8.2'
implementation 'androidx.activity:activity-ktx:1.9.1'
implementation 'androidx.fragment:fragment-ktx:1.8.2'
implementation 'androidx.transition:transition-ktx:1.5.1'
implementation 'androidx.collection:collection-ktx:1.4.3'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4'
implementation 'androidx.lifecycle:lifecycle-service:2.8.4'
implementation 'androidx.lifecycle:lifecycle-process:2.8.4'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
@@ -106,10 +106,10 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.2'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.4'
implementation 'androidx.webkit:webkit:1.11.0'
implementation 'androidx.work:work-runtime:2.9.0'
implementation 'androidx.work:work-runtime:2.9.1'
//noinspection GradleDependency
implementation('com.google.guava:guava:32.0.1-android') {
exclude group: 'com.google.guava', module: 'failureaccess'
@@ -134,9 +134,9 @@ dependencies {
implementation 'androidx.hilt:hilt-work:1.2.0'
kapt 'androidx.hilt:hilt-compiler:1.2.0'
implementation 'io.coil-kt:coil-base:2.6.0'
implementation 'io.coil-kt:coil-svg:2.6.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:8cafac256e'
implementation 'io.coil-kt:coil-base:2.7.0'
implementation 'io.coil-kt:coil-svg:2.7.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:882bc0620c'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'
@@ -154,10 +154,10 @@ dependencies {
testImplementation 'org.json:json:20240303'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
androidTestImplementation 'androidx.test:runner:1.6.1'
androidTestImplementation 'androidx.test:rules:1.6.1'
androidTestImplementation 'androidx.test:core-ktx:1.6.1'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'

View File

@@ -20,6 +20,10 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES"/>
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />

View File

@@ -18,178 +18,178 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import javax.inject.Inject
class MigrateUseCase
@Inject
constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val mangaDataRepository: MangaDataRepository,
private val database: MangaDatabase,
private val progressUpdateUseCase: ProgressUpdateUseCase,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
@Inject
constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val mangaDataRepository: MangaDataRepository,
private val database: MangaDatabase,
private val progressUpdateUseCase: ProgressUpdateUseCase,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
) {
suspend operator fun invoke(
oldManga: Manga,
newManga: Manga,
) {
suspend operator fun invoke(
oldManga: Manga,
newManga: Manga,
) {
val oldDetails =
if (oldManga.chapters.isNullOrEmpty()) {
runCatchingCancellable {
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
}.getOrDefault(oldManga)
} else {
oldManga
}
val newDetails =
if (newManga.chapters.isNullOrEmpty()) {
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
} else {
newManga
}
mangaDataRepository.storeManga(newDetails)
database.withTransaction {
// replace favorites
val favoritesDao = database.getFavouritesDao()
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
if (oldFavourites.isNotEmpty()) {
favoritesDao.delete(oldManga.id)
for (f in oldFavourites) {
val e =
f.copy(
mangaId = newManga.id,
)
favoritesDao.upsert(e)
}
}
// replace history
val historyDao = database.getHistoryDao()
val oldHistory = historyDao.find(oldDetails.id)
val newHistory =
if (oldHistory != null) {
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
historyDao.delete(oldDetails.id)
historyDao.upsert(newHistory)
newHistory
} else {
null
}
// track
val tracksDao = database.getTracksDao()
val oldTrack = tracksDao.find(oldDetails.id)
if (oldTrack != null) {
val lastChapter = newDetails.chapters?.lastOrNull()
val newTrack =
TrackEntity(
mangaId = newDetails.id,
lastChapterId = lastChapter?.id ?: 0L,
newChapters = 0,
lastCheckTime = System.currentTimeMillis(),
lastChapterDate = lastChapter?.uploadDate ?: 0L,
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
lastError = null,
val oldDetails =
if (oldManga.chapters.isNullOrEmpty()) {
runCatchingCancellable {
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
}.getOrDefault(oldManga)
} else {
oldManga
}
val newDetails =
if (newManga.chapters.isNullOrEmpty()) {
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
} else {
newManga
}
mangaDataRepository.storeManga(newDetails)
database.withTransaction {
// replace favorites
val favoritesDao = database.getFavouritesDao()
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
if (oldFavourites.isNotEmpty()) {
favoritesDao.delete(oldManga.id)
for (f in oldFavourites) {
val e =
f.copy(
mangaId = newManga.id,
)
tracksDao.delete(oldDetails.id)
tracksDao.upsert(newTrack)
favoritesDao.upsert(e)
}
// scrobbling
for (scrobbler in scrobblers) {
if (!scrobbler.isEnabled) {
continue
}
val prevInfo = scrobbler.getScrobblingInfoOrNull(oldDetails.id) ?: continue
scrobbler.unregisterScrobbling(oldDetails.id)
scrobbler.linkManga(newDetails.id, prevInfo.targetId)
scrobbler.updateScrobblingInfo(
}
// replace history
val historyDao = database.getHistoryDao()
val oldHistory = historyDao.find(oldDetails.id)
val newHistory =
if (oldHistory != null) {
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
historyDao.delete(oldDetails.id)
historyDao.upsert(newHistory)
newHistory
} else {
null
}
// track
val tracksDao = database.getTracksDao()
val oldTrack = tracksDao.find(oldDetails.id)
if (oldTrack != null) {
val lastChapter = newDetails.chapters?.lastOrNull()
val newTrack =
TrackEntity(
mangaId = newDetails.id,
rating = prevInfo.rating,
status =
prevInfo.status ?: when {
newHistory == null -> ScrobblingStatus.PLANNED
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
else -> ScrobblingStatus.READING
},
comment = prevInfo.comment,
lastChapterId = lastChapter?.id ?: 0L,
newChapters = 0,
lastCheckTime = System.currentTimeMillis(),
lastChapterDate = lastChapter?.uploadDate ?: 0L,
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
lastError = null,
)
if (newHistory != null) {
scrobbler.scrobble(
manga = newDetails,
chapterId = newHistory.chapterId,
)
}
}
tracksDao.delete(oldDetails.id)
tracksDao.upsert(newTrack)
}
progressUpdateUseCase(newManga)
}
private fun makeNewHistory(
oldManga: Manga,
newManga: Manga,
history: HistoryEntity,
): HistoryEntity {
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
val branch = newManga.getPreferredBranch(null)
val chapters = checkNotNull(newManga.getChapters(branch))
val currentChapter =
if (history.percent in 0f..1f) {
chapters[(chapters.lastIndex * history.percent).toInt()]
} else {
chapters.first()
}
return HistoryEntity(
mangaId = newManga.id,
createdAt = history.createdAt,
updatedAt = System.currentTimeMillis(),
chapterId = currentChapter.id,
page = history.page,
scroll = history.scroll,
percent = history.percent,
deletedAt = 0,
chaptersCount = chapters.size,
// scrobbling
for (scrobbler in scrobblers) {
if (!scrobbler.isEnabled) {
continue
}
val prevInfo = scrobbler.getScrobblingInfoOrNull(oldDetails.id) ?: continue
scrobbler.unregisterScrobbling(oldDetails.id)
scrobbler.linkManga(newDetails.id, prevInfo.targetId)
scrobbler.updateScrobblingInfo(
mangaId = newDetails.id,
rating = prevInfo.rating,
status =
prevInfo.status ?: when {
newHistory == null -> ScrobblingStatus.PLANNED
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
else -> ScrobblingStatus.READING
},
comment = prevInfo.comment,
)
}
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
val oldChapters = checkNotNull(oldManga.getChapters(branch))
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
if (index < 0) {
index =
if (history.percent in 0f..1f) {
(oldChapters.lastIndex * history.percent).toInt()
} else {
0
}
}
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
val newBranch =
if (newChapters.containsKey(branch)) {
branch
} else {
newManga.getPreferredBranch(null)
if (newHistory != null) {
scrobbler.scrobble(
manga = newDetails,
chapterId = newHistory.chapterId,
)
}
val newChapterId =
checkNotNull(newChapters[newBranch])
.let {
val oldChapter = oldChapters[index]
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
}.id
}
}
progressUpdateUseCase(newManga)
}
private fun makeNewHistory(
oldManga: Manga,
newManga: Manga,
history: HistoryEntity,
): HistoryEntity {
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
val branch = newManga.getPreferredBranch(null)
val chapters = checkNotNull(newManga.getChapters(branch))
val currentChapter =
if (history.percent in 0f..1f) {
chapters[(chapters.lastIndex * history.percent).toInt()]
} else {
chapters.first()
}
return HistoryEntity(
mangaId = newManga.id,
createdAt = history.createdAt,
updatedAt = System.currentTimeMillis(),
chapterId = newChapterId,
chapterId = currentChapter.id,
page = history.page,
scroll = history.scroll,
percent = PROGRESS_NONE,
percent = history.percent,
deletedAt = 0,
chaptersCount = checkNotNull(newChapters[newBranch]).size,
chaptersCount = chapters.count { it.branch == currentChapter.branch },
)
}
private fun List<MangaChapter>.findByNumber(
volume: Int,
number: Float,
): MangaChapter? =
if (number <= 0f) {
null
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
val oldChapters = checkNotNull(oldManga.getChapters(branch))
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
if (index < 0) {
index =
if (history.percent in 0f..1f) {
(oldChapters.lastIndex * history.percent).toInt()
} else {
0
}
}
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
val newBranch =
if (newChapters.containsKey(branch)) {
branch
} else {
firstOrNull { it.volume == volume && it.number == number }
newManga.getPreferredBranch(null)
}
val newChapterId =
checkNotNull(newChapters[newBranch])
.let {
val oldChapter = oldChapters[index]
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
}.id
return HistoryEntity(
mangaId = newManga.id,
createdAt = history.createdAt,
updatedAt = System.currentTimeMillis(),
chapterId = newChapterId,
page = history.page,
scroll = history.scroll,
percent = PROGRESS_NONE,
deletedAt = 0,
chaptersCount = checkNotNull(newChapters[newBranch]).size,
)
}
private fun List<MangaChapter>.findByNumber(
volume: Int,
number: Float,
): MangaChapter? =
if (number <= 0f) {
null
} else {
firstOrNull { it.volume == volume && it.number == number }
}
}

View File

@@ -62,7 +62,7 @@ fun alternativeAD(
}
}
}
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
binding.progressView.setProgress(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
binding.chipSource.also { chip ->
chip.text = item.manga.source.getTitle(chip.context)
ImageRequest.Builder(context)

View File

@@ -15,11 +15,13 @@ import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
@@ -34,7 +36,8 @@ class AlternativesViewModel @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val alternativesUseCase: AlternativesUseCase,
private val migrateUseCase: MigrateUseCase,
private val extraProvider: ListExtraProvider,
private val historyRepository: HistoryRepository,
private val settings: AppSettings,
) : BaseViewModel() {
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
@@ -53,7 +56,7 @@ class AlternativesViewModel @Inject constructor(
.map {
MangaAlternativeModel(
manga = it,
progress = extraProvider.getProgress(it.id),
progress = getProgress(it.id),
referenceChapters = refCount,
)
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
@@ -86,13 +89,7 @@ class AlternativesViewModel @Inject constructor(
}
}
private suspend fun mapList(list: List<Manga>, refCount: Int): List<MangaAlternativeModel> {
return list.map {
MangaAlternativeModel(
manga = it,
progress = extraProvider.getProgress(it.id),
referenceChapters = refCount,
)
}
private suspend fun getProgress(mangaId: Long): ReadingProgress? {
return historyRepository.getProgress(mangaId, settings.progressIndicatorMode)
}
}

View File

@@ -1,12 +1,13 @@
package org.koitharu.kotatsu.alternatives.ui
import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
data class MangaAlternativeModel(
val manga: Manga,
val progress: Float,
val progress: ReadingProgress?,
private val referenceChapters: Int,
) : ListModel {

View File

@@ -37,6 +37,6 @@ fun bookmarkLargeAD(
source(item.manga.source)
enqueueWith(coil)
}
binding.progressView.percent = item.percent
binding.progressView.setProgress(item.percent, false)
}
}

View File

@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
@@ -44,8 +44,8 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
val repository = mangaRepositoryFactory.create(mangaSource) as? RemoteMangaRepository
repository?.headers?.get(CommonHeaders.USER_AGENT)
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
viewBinding.webView.configureForParser(userAgent)
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
viewBinding.webView.webViewClient = BrowserClient(this)

View File

@@ -55,7 +55,11 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
val url = intent?.dataString.orEmpty()
val url = intent?.dataString
if (url.isNullOrEmpty()) {
finishAfterTransition()
return
}
cfClient = CloudFlareClient(cookieJar, this, url)
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
viewBinding.webView.webViewClient = cfClient
@@ -63,12 +67,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
onBackPressedDispatcher.addCallback(it)
}
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
if (savedInstanceState != null) {
return
}
if (url.isEmpty()) {
finishAfterTransition()
} else {
if (savedInstanceState == null) {
onTitleChanged(getString(R.string.loading_), url)
viewBinding.webView.loadUrl(url)
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core
import android.app.Application
import android.content.ContentResolver
import android.content.Context
import android.provider.SearchRecentSuggestions
import android.text.Html
@@ -110,6 +111,8 @@ interface AppModule {
.decoderDispatcher(Dispatchers.IO)
.transformationDispatcher(Dispatchers.Default)
.diskCache(diskCacheFactory)
.respectCacheHeaders(false)
.networkObserverEnabled(false)
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
.allowRgb565(context.isLowRamDevice())
.eventListener(CaptchaNotifier(context))

View File

@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
class JsonDeserializer(private val json: JSONObject) {
@@ -85,6 +86,8 @@ class JsonDeserializer(private val json: JSONObject) {
isEnabled = json.getBoolean("enabled"),
sortKey = json.getInt("sort_key"),
addedIn = json.getIntOrDefault("added_in", 0),
lastUsedAt = json.getLongOrDefault("used_at", 0L),
isPinned = json.getBooleanOrDefault("pinned", false),
)
fun toMap(): Map<String, Any?> {

View File

@@ -89,6 +89,9 @@ class JsonSerializer private constructor(private val json: JSONObject) {
put("source", e.source)
put("enabled", e.isEnabled)
put("sort_key", e.sortKey)
put("added_in", e.addedIn)
put("used_at", e.lastUsedAt)
put("pinned", e.isPinned)
},
)

View File

@@ -34,6 +34,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration18To19
import org.koitharu.kotatsu.core.db.migrations.Migration19To20
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
import org.koitharu.kotatsu.core.db.migrations.Migration21To22
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
@@ -59,7 +60,7 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 21
const val DATABASE_VERSION = 22
@Database(
entities = [
@@ -120,6 +121,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration18To19(),
Migration19To20(),
Migration20To21(),
Migration21To22(),
)
fun MangaDatabase(context: Context): MangaDatabase = Room

View File

@@ -18,7 +18,7 @@ import org.koitharu.kotatsu.explore.data.SourcesSortOrder
@Dao
abstract class MangaSourcesDao {
@Query("SELECT * FROM sources ORDER BY sort_key")
@Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key")
abstract suspend fun findAll(): List<MangaSourceEntity>
@Query("SELECT source FROM sources WHERE enabled = 1")
@@ -27,7 +27,10 @@ abstract class MangaSourcesDao {
@Query("SELECT * FROM sources WHERE added_in >= :version")
abstract suspend fun findAllFromVersion(version: Int): List<MangaSourceEntity>
@Query("SELECT * FROM sources ORDER BY sort_key")
@Query("SELECT * FROM sources ORDER BY used_at DESC LIMIT :limit")
abstract suspend fun findLastUsed(limit: Int): List<MangaSourceEntity>
@Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key")
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
@Query("SELECT enabled FROM sources WHERE source = :source")
@@ -42,6 +45,12 @@ abstract class MangaSourcesDao {
@Query("UPDATE sources SET sort_key = :sortKey WHERE source = :source")
abstract suspend fun setSortKey(source: String, sortKey: Int)
@Query("UPDATE sources SET used_at = :value WHERE source = :source")
abstract suspend fun setLastUsed(source: String, value: Long)
@Query("UPDATE sources SET pinned = :isPinned WHERE source = :source")
abstract suspend fun setPinned(source: String, isPinned: Boolean)
@Insert(onConflict = OnConflictStrategy.IGNORE)
@Transaction
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
@@ -49,11 +58,14 @@ abstract class MangaSourcesDao {
@Upsert
abstract suspend fun upsert(entry: MangaSourceEntity)
@Query("SELECT * FROM sources WHERE pinned = 1")
abstract suspend fun findAllPinned(): List<MangaSourceEntity>
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
val orderBy = getOrderBy(order)
@Language("RoomSql")
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
return observeImpl(query)
}
@@ -61,7 +73,7 @@ abstract class MangaSourcesDao {
val orderBy = getOrderBy(order)
@Language("RoomSql")
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
return findAllImpl(query)
}
@@ -73,6 +85,8 @@ abstract class MangaSourcesDao {
isEnabled = isEnabled,
sortKey = getMaxSortKey() + 1,
addedIn = BuildConfig.VERSION_CODE,
lastUsedAt = 0,
isPinned = false,
)
upsert(entity)
}
@@ -91,5 +105,6 @@ abstract class MangaSourcesDao {
SourcesSortOrder.ALPHABETIC -> "source ASC"
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
SourcesSortOrder.MANUAL -> "sort_key ASC"
SourcesSortOrder.LAST_USED -> "used_at DESC"
}
}

View File

@@ -15,4 +15,6 @@ data class MangaSourceEntity(
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
@ColumnInfo(name = "added_in") val addedIn: Int,
@ColumnInfo(name = "used_at") val lastUsedAt: Long,
@ColumnInfo(name = "pinned") val isPinned: Boolean,
)

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration21To22 : Migration(21, 22) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE sources ADD COLUMN `used_at` INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE sources ADD COLUMN `pinned` INTEGER NOT NULL DEFAULT 0")
}
}

View File

@@ -0,0 +1,6 @@
package org.koitharu.kotatsu.core.exceptions
class IncompatiblePluginException(
val name: String?,
cause: Throwable?,
) : RuntimeException(cause)

View File

@@ -9,12 +9,14 @@ import android.text.style.SuperscriptSpan
import androidx.annotation.StringRes
import androidx.core.text.inSpans
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
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.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.splitTwoParts
import com.google.android.material.R as materialR
data object LocalMangaSource : MangaSource {
@@ -26,11 +28,14 @@ data object UnknownMangaSource : MangaSource {
}
fun MangaSource(name: String?): MangaSource {
when (name) {
null,
UnknownMangaSource.name -> UnknownMangaSource
when (name ?: return UnknownMangaSource) {
UnknownMangaSource.name -> return UnknownMangaSource
LocalMangaSource.name -> LocalMangaSource
LocalMangaSource.name -> return LocalMangaSource
}
if (name.startsWith("content:")) {
val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownMangaSource
return ExternalMangaSource(packageName = parts.first, authority = parts.second)
}
MangaParserSource.entries.forEach {
if (it.name == name) return it
@@ -38,7 +43,8 @@ fun MangaSource(name: String?): MangaSource {
return UnknownMangaSource
}
fun MangaSource.isNsfw() = when (this) {
fun MangaSource.isNsfw(): Boolean = when (this) {
is MangaSourceInfo -> mangaSource.isNsfw()
is MangaParserSource -> contentType == ContentType.HENTAI
else -> false
}
@@ -53,18 +59,23 @@ val ContentType.titleResId
}
fun MangaSource.getSummary(context: Context): String? = when (this) {
is MangaSourceInfo -> mangaSource.getSummary(context)
is MangaParserSource -> {
val type = context.getString(contentType.titleResId)
val locale = locale.toLocale().getDisplayName(context)
context.getString(R.string.source_summary_pattern, type, locale)
}
is ExternalMangaSource -> context.getString(R.string.external_source)
else -> null
}
fun MangaSource.getTitle(context: Context): String = when (this) {
is MangaSourceInfo -> mangaSource.getTitle(context)
is MangaParserSource -> title
LocalMangaSource -> context.getString(R.string.local_storage)
is ExternalMangaSource -> resolveName(context)
else -> context.getString(R.string.unknown)
}

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.core.model
import org.koitharu.kotatsu.parsers.model.MangaSource
data class MangaSourceInfo(
val mangaSource: MangaSource,
val isEnabled: Boolean,
val isPinned: Boolean,
) : MangaSource by mangaSource

View File

@@ -11,7 +11,7 @@ import okio.IOException
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mergeWith
@@ -30,7 +30,7 @@ class CommonHeadersInterceptor @Inject constructor(
val request = chain.request()
val source = request.tag(MangaSource::class.java)
val repository = if (source != null) {
mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
} else {
if (BuildConfig.DEBUG) {
Log.w("Http", "Request without source tag: ${request.url}")
@@ -38,7 +38,7 @@ class CommonHeadersInterceptor @Inject constructor(
null
}
val headersBuilder = request.headers.newBuilder()
repository?.headers?.let {
repository?.getRequestHeaders()?.let {
headersBuilder.mergeWith(it, replaceExisting = false)
}
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {

View File

@@ -13,7 +13,7 @@ import okhttp3.internal.canParseAsIpAddress
import okhttp3.internal.closeQuietly
import okhttp3.internal.publicsuffix.PublicSuffixDatabase
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -54,7 +54,7 @@ class MirrorSwitchInterceptor @Inject constructor(
}
}
suspend fun trySwitchMirror(repository: RemoteMangaRepository): Boolean = runInterruptible(Dispatchers.Default) {
suspend fun trySwitchMirror(repository: ParserMangaRepository): Boolean = runInterruptible(Dispatchers.Default) {
if (!isEnabled) {
return@runInterruptible false
}
@@ -76,14 +76,14 @@ class MirrorSwitchInterceptor @Inject constructor(
}
}
fun rollback(repository: RemoteMangaRepository, oldMirror: String) = synchronized(obtainLock(repository.source)) {
fun rollback(repository: ParserMangaRepository, oldMirror: String) = synchronized(obtainLock(repository.source)) {
blacklist[repository.source]?.remove(oldMirror)
repository.domain = oldMirror
}
private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? {
val source = request.tag(MangaSource::class.java) ?: return null
val repository = mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository ?: return null
val repository = mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository ?: return null
val mirrors = repository.getAvailableMirrors()
if (mirrors.isEmpty()) {
return null
@@ -94,7 +94,7 @@ class MirrorSwitchInterceptor @Inject constructor(
}
private fun tryMirrors(
repository: RemoteMangaRepository,
repository: ParserMangaRepository,
mirrors: List<String>,
chain: Interceptor.Chain,
request: Request,

View File

@@ -0,0 +1,104 @@
package org.koitharu.kotatsu.core.parser
import android.util.Log
import androidx.collection.MutableLongSet
import coil.request.CachePolicy
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.SafeDeferred
import org.koitharu.kotatsu.core.util.MultiMutex
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
abstract class CachingMangaRepository(
private val cache: MemoryContentCache,
) : MangaRepository {
private val detailsMutex = MultiMutex<Long>()
private val relatedMangaMutex = MultiMutex<Long>()
private val pagesMutex = MultiMutex<Long>()
final override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
final override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = pagesMutex.withLock(chapter.id) {
cache.getPages(source, chapter.url)?.let { return it }
val pages = asyncSafe {
getPagesImpl(chapter).distinctById()
}
cache.putPages(source, chapter.url, pages)
pages
}.await()
final override suspend fun getRelated(seed: Manga): List<Manga> = relatedMangaMutex.withLock(seed.id) {
cache.getRelatedManga(source, seed.url)?.let { return it }
val related = asyncSafe {
getRelatedMangaImpl(seed).filterNot { it.id == seed.id }
}
cache.putRelatedManga(source, seed.url, related)
related
}.await()
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga = detailsMutex.withLock(manga.id) {
if (cachePolicy.readEnabled) {
cache.getDetails(source, manga.url)?.let { return it }
}
val details = asyncSafe {
getDetailsImpl(manga)
}
if (cachePolicy.writeEnabled) {
cache.putDetails(source, manga.url, details)
}
details
}.await()
suspend fun peekDetails(manga: Manga): Manga? {
return cache.getDetails(source, manga.url)
}
fun invalidateCache() {
cache.clear(source)
}
protected abstract suspend fun getDetailsImpl(manga: Manga): Manga
protected abstract suspend fun getRelatedMangaImpl(seed: Manga): List<Manga>
protected abstract suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage>
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
dispatcher = Dispatchers.Default
}
return SafeDeferred(
processLifecycleScope.async(dispatcher) {
runCatchingCancellable { block() }
},
)
}
private fun List<MangaPage>.distinctById(): List<MangaPage> {
if (isEmpty()) {
return emptyList()
}
val result = ArrayList<MangaPage>(size)
val set = MutableLongSet(size)
for (page in this) {
if (set.add(page.id)) {
result.add(page)
} else if (BuildConfig.DEBUG) {
Log.w(null, "Duplicate page: $page")
}
}
return result
}
}

View File

@@ -13,9 +13,6 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet
import java.util.Locale
/**
* This parser is just for parser development, it should not be used in releases
*/
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
override val sortOrders: Set<SortOrder>

View File

@@ -52,7 +52,7 @@ class MangaLinkResolver @Inject constructor(
val host = uri.host ?: return null
val repo = sourcesRepository.allMangaSources.asSequence()
.map { source ->
repositoryFactory.create(source) as RemoteMangaRepository
repositoryFactory.create(source) as ParserMangaRepository
}.find { repo ->
host in repo.domains
} ?: return null
@@ -86,7 +86,7 @@ class MangaLinkResolver @Inject constructor(
}
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
return if (this is RemoteMangaRepository) {
return if (this is ParserMangaRepository) {
getDetails(manga, CachePolicy.READ_ONLY)
} else {
getDetails(manga)

View File

@@ -1,11 +1,16 @@
package org.koitharu.kotatsu.core.parser
import android.content.Context
import androidx.annotation.AnyThread
import androidx.collection.ArrayMap
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.ContentRating
@@ -56,8 +61,14 @@ interface MangaRepository {
suspend fun getRelated(seed: Manga): List<Manga>
suspend fun find(manga: Manga): Manga? {
val list = getList(0, MangaListFilter.Search(manga.title))
return list.find { x -> x.id == manga.id }
}
@Singleton
class Factory @Inject constructor(
@ApplicationContext private val context: Context,
private val localMangaRepository: LocalMangaRepository,
private val loaderContext: MangaLoaderContext,
private val contentCache: MemoryContentCache,
@@ -69,6 +80,7 @@ interface MangaRepository {
@AnyThread
fun create(source: MangaSource): MangaRepository {
when (source) {
is MangaSourceInfo -> return create(source.mangaSource)
LocalMangaSource -> return localMangaRepository
UnknownMangaSource -> return EmptyMangaRepository(source)
}
@@ -86,12 +98,22 @@ interface MangaRepository {
}
private fun createRepository(source: MangaSource): MangaRepository? = when (source) {
is MangaParserSource -> RemoteMangaRepository(
is MangaParserSource -> ParserMangaRepository(
parser = MangaParser(source, loaderContext),
cache = contentCache,
mirrorSwitchInterceptor = mirrorSwitchInterceptor,
)
is ExternalMangaSource -> if (source.isAvailable(context)) {
ExternalMangaRepository(
contentResolver = context.contentResolver,
source = source,
cache = contentCache,
)
} else {
EmptyMangaRepository(source)
}
else -> null
}
}

View File

@@ -1,24 +1,10 @@
package org.koitharu.kotatsu.core.parser
import android.util.Log
import androidx.collection.MutableLongSet
import coil.request.CachePolicy
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.SafeDeferred
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.MultiMutex
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey
@@ -36,15 +22,11 @@ import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Locale
class RemoteMangaRepository(
class ParserMangaRepository(
private val parser: MangaParser,
private val cache: MemoryContentCache,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
) : MangaRepository, Interceptor {
private val detailsMutex = MultiMutex<Long>()
private val relatedMangaMutex = MultiMutex<Long>()
private val pagesMutex = MultiMutex<Long>()
cache: MemoryContentCache,
) : CachingMangaRepository(cache), Interceptor {
override val source: MangaParserSource
get() = parser.source
@@ -82,9 +64,6 @@ class RemoteMangaRepository(
val domains: Array<out String>
get() = parser.configKeyDomain.presetValues
val headers: Headers
get() = parser.headers
override fun intercept(chain: Interceptor.Chain): Response {
return if (parser is Interceptor) {
parser.intercept(chain)
@@ -99,18 +78,11 @@ class RemoteMangaRepository(
}
}
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = pagesMutex.withLock(chapter.id) {
cache.getPages(source, chapter.url)?.let { return it }
val pages = asyncSafe {
mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPages(chapter).distinctById()
}
}
cache.putPages(source, chapter.url, pages)
pages
}.await()
override suspend fun getPagesImpl(
chapter: MangaChapter
): List<MangaPage> = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPages(chapter)
}
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPageUrl(page)
@@ -128,41 +100,16 @@ class RemoteMangaRepository(
parser.getFavicons()
}
override suspend fun getRelated(seed: Manga): List<Manga> = relatedMangaMutex.withLock(seed.id) {
cache.getRelatedManga(source, seed.url)?.let { return it }
val related = asyncSafe {
parser.getRelatedManga(seed).filterNot { it.id == seed.id }
}
cache.putRelatedManga(source, seed.url, related)
related
}.await()
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = parser.getRelatedManga(seed)
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga = detailsMutex.withLock(manga.id) {
if (cachePolicy.readEnabled) {
cache.getDetails(source, manga.url)?.let { return it }
}
val details = asyncSafe {
mirrorSwitchInterceptor.withMirrorSwitching {
parser.getDetails(manga)
}
}
if (cachePolicy.writeEnabled) {
cache.putDetails(source, manga.url, details)
}
details
}.await()
suspend fun peekDetails(manga: Manga): Manga? {
return cache.getDetails(source, manga.url)
}
suspend fun find(manga: Manga): Manga? {
val list = getList(0, MangaListFilter.Search(manga.title))
return list.find { x -> x.id == manga.id }
override suspend fun getDetailsImpl(manga: Manga): Manga = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getDetails(manga)
}
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
fun getRequestHeaders() = parser.getRequestHeaders()
fun getConfigKeys(): List<ConfigKey<*>> = ArrayList<ConfigKey<*>>().also {
parser.onCreateConfig(it)
}
@@ -175,40 +122,8 @@ class RemoteMangaRepository(
return getConfig().isSlowdownEnabled
}
fun invalidateCache() {
cache.clear(source)
}
fun getConfig() = parser.config as SourceSettings
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
dispatcher = Dispatchers.Default
}
return SafeDeferred(
processLifecycleScope.async(dispatcher) {
runCatchingCancellable { block() }
},
)
}
private fun List<MangaPage>.distinctById(): List<MangaPage> {
if (isEmpty()) {
return emptyList()
}
val result = ArrayList<MangaPage>(size)
val set = MutableLongSet(size)
for (page in this) {
if (set.add(page.id)) {
result.add(page)
} else if (BuildConfig.DEBUG) {
Log.w(null, "Duplicate page: $page")
}
}
return result
}
private suspend fun <R> MirrorSwitchInterceptor.withMirrorSwitching(block: suspend () -> R): R {
if (!isEnabled) {
return block()
@@ -220,14 +135,14 @@ class RemoteMangaRepository(
if (result.isValidResult()) {
return result.getOrThrow()
}
return if (trySwitchMirror(this@RemoteMangaRepository)) {
return if (trySwitchMirror(this@ParserMangaRepository)) {
val newResult = runCatchingCancellable {
block()
}
if (newResult.isValidResult()) {
return newResult.getOrThrow()
} else {
rollback(this@RemoteMangaRepository, initialMirror)
rollback(this@ParserMangaRepository, initialMirror)
return result.getOrThrow()
}
} else {

View File

@@ -0,0 +1,80 @@
package org.koitharu.kotatsu.core.parser.external
import android.content.ContentResolver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet
import java.util.Locale
class ExternalMangaRepository(
private val contentResolver: ContentResolver,
override val source: ExternalMangaSource,
cache: MemoryContentCache,
) : CachingMangaRepository(cache) {
private val contentSource = ExternalPluginContentSource(contentResolver, source)
private val capabilities by lazy {
runCatching {
contentSource.getCapabilities()
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
}
override val sortOrders: Set<SortOrder>
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL)
override val states: Set<MangaState>
get() = capabilities?.availableStates.orEmpty()
override val contentRatings: Set<ContentRating>
get() = capabilities?.availableContentRating.orEmpty()
override var defaultSortOrder: SortOrder
get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL
set(value) = Unit
override val isMultipleTagsSupported: Boolean
get() = capabilities?.isMultipleTagsSupported ?: true
override val isTagsExclusionSupported: Boolean
get() = capabilities?.isTagsExclusionSupported ?: false
override val isSearchSupported: Boolean
get() = capabilities?.isSearchSupported ?: true
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> =
runInterruptible(Dispatchers.IO) {
contentSource.getList(offset, filter)
}
override suspend fun getDetailsImpl(manga: Manga): Manga = runInterruptible(Dispatchers.IO) {
contentSource.getDetails(manga)
}
override suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
contentSource.getPages(chapter)
}
override suspend fun getPageUrl(page: MangaPage): String = page.url // TODO
override suspend fun getTags(): Set<MangaTag> = runInterruptible(Dispatchers.IO) {
contentSource.getTags()
}
override suspend fun getLocales(): Set<Locale> = emptySet() // TODO
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO
}

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.core.parser.external
import android.content.Context
import org.koitharu.kotatsu.parsers.model.MangaSource
data class ExternalMangaSource(
val packageName: String,
val authority: String,
) : MangaSource {
override val name: String
get() = "content:$packageName/$authority"
private var cachedName: String? = null
fun isAvailable(context: Context): Boolean {
return context.packageManager.resolveContentProvider(authority, 0)?.isEnabled == true
}
fun resolveName(context: Context): String {
cachedName?.let {
return it
}
val pm = context.packageManager
val info = pm.resolveContentProvider(authority, 0)
return info?.loadLabel(pm)?.toString()?.also {
cachedName = it
} ?: authority
}
}

View File

@@ -0,0 +1,291 @@
package org.koitharu.kotatsu.core.parser.external
import android.content.ContentResolver
import android.database.Cursor
import androidx.annotation.WorkerThread
import androidx.collection.ArraySet
import androidx.core.net.toUri
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.toLocale
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.splitTwoParts
import java.util.EnumSet
import java.util.Locale
class ExternalPluginContentSource(
private val contentResolver: ContentResolver,
private val source: ExternalMangaSource,
) {
@Blocking
@WorkerThread
fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = runCatchingCompatibility {
val uri = "content://${source.authority}/manga".toUri().buildUpon()
uri.appendQueryParameter("offset", offset.toString())
when (filter) {
is MangaListFilter.Advanced -> {
filter.tags.forEach { uri.appendQueryParameter("tag_include", it.key) }
filter.tagsExclude.forEach { uri.appendQueryParameter("tag_exclude", it.key) }
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
}
is MangaListFilter.Search -> {
uri.appendQueryParameter("query", filter.query)
}
null -> Unit
}
contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name)
.safe()
.use { cursor ->
val result = ArrayList<Manga>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += cursor.getManga()
} while (cursor.moveToNext())
}
result
}
}
@Blocking
@WorkerThread
fun getDetails(manga: Manga) = runCatchingCompatibility {
val chapters = queryChapters(manga.url)
val details = queryDetails(manga.url)
Manga(
id = manga.id,
title = details.title.ifBlank { manga.title },
altTitle = details.altTitle.ifNullOrEmpty { manga.altTitle },
url = details.url.ifEmpty { manga.url },
publicUrl = details.publicUrl.ifEmpty { manga.publicUrl },
rating = maxOf(details.rating, manga.rating),
isNsfw = details.isNsfw,
coverUrl = details.coverUrl.ifEmpty { manga.coverUrl },
tags = details.tags + manga.tags,
state = details.state ?: manga.state,
author = details.author.ifNullOrEmpty { manga.author },
largeCoverUrl = details.largeCoverUrl.ifNullOrEmpty { manga.largeCoverUrl },
description = details.description.ifNullOrEmpty { manga.description },
chapters = chapters,
source = source,
)
}
@Blocking
@WorkerThread
fun getPages(chapter: MangaChapter): List<MangaPage> = runCatchingCompatibility {
val uri = "content://${source.authority}/chapters".toUri()
.buildUpon()
.appendPath(chapter.url)
.build()
contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
val result = ArrayList<MangaPage>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaPage(
id = cursor.getLong(COLUMN_ID),
url = cursor.getString(COLUMN_URL),
preview = cursor.getStringOrNull(COLUMN_PREVIEW),
source = source,
)
} while (cursor.moveToNext())
}
result
}
}
@Blocking
@WorkerThread
fun getTags(): Set<MangaTag> = runCatchingCompatibility {
val uri = "content://${source.authority}/tags".toUri()
contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
val result = ArraySet<MangaTag>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaTag(
key = cursor.getString(COLUMN_KEY),
title = cursor.getString(COLUMN_TITLE),
source = source,
)
} while (cursor.moveToNext())
}
result
}
}
fun getCapabilities(): MangaSourceCapabilities? {
val uri = "content://${source.authority}/capabilities".toUri()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
if (cursor.moveToFirst()) {
MangaSourceCapabilities(
availableSortOrders = cursor.getStringOrNull(COLUMN_SORT_ORDERS)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) {
SortOrder.entries.find(it)
}.orEmpty(),
availableStates = cursor.getStringOrNull(COLUMN_STATES)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(MangaState::class.java)) {
MangaState.entries.find(it)
}.orEmpty(),
availableContentRating = cursor.getStringOrNull(COLUMN_CONTENT_RATING)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(ContentRating::class.java)) {
ContentRating.entries.find(it)
}.orEmpty(),
isMultipleTagsSupported = cursor.getBooleanOrDefault(COLUMN_MULTIPLE_TAGS_SUPPORTED, true),
isTagsExclusionSupported = cursor.getBooleanOrDefault(COLUMN_TAGS_EXCLUSION_SUPPORTED, false),
isSearchSupported = cursor.getBooleanOrDefault(COLUMN_SEARCH_SUPPORTED, true),
contentType = cursor.getStringOrNull(COLUMN_CONTENT_TYPE)?.let {
ContentType.entries.find(it)
} ?: ContentType.OTHER,
defaultSortOrder = cursor.getStringOrNull(COLUMN_DEFAULT_SORT_ORDER)?.let {
SortOrder.entries.find(it)
} ?: SortOrder.ALPHABETICAL,
sourceLocale = cursor.getStringOrNull(COLUMN_LOCALE)?.toLocale() ?: Locale.ROOT,
)
} else {
null
}
}
}
private fun queryDetails(url: String): Manga {
val uri = "content://${source.authority}/manga".toUri()
.buildUpon()
.appendPath(url)
.build()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
cursor.moveToFirst()
cursor.getManga()
}
}
private fun queryChapters(url: String): List<MangaChapter> {
val uri = "content://${source.authority}/manga/chapters".toUri()
.buildUpon()
.appendPath(url)
.build()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
val result = ArrayList<MangaChapter>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaChapter(
id = cursor.getLong(COLUMN_ID),
name = cursor.getString(COLUMN_NAME),
number = cursor.getFloatOrDefault(COLUMN_NUMBER, 0f),
volume = cursor.getIntOrDefault(COLUMN_VOLUME, 0),
url = cursor.getString(COLUMN_URL),
scanlator = cursor.getStringOrNull(COLUMN_SCANLATOR),
uploadDate = cursor.getLongOrDefault(COLUMN_UPLOAD_DATE, 0L),
branch = cursor.getStringOrNull(COLUMN_BRANCH),
source = source,
)
} while (cursor.moveToNext())
}
result
}
}
private fun SafeCursor.getManga() = Manga(
id = getLong(COLUMN_ID),
title = getString(COLUMN_TITLE),
altTitle = getStringOrNull(COLUMN_ALT_TITLE),
url = getString(COLUMN_URL),
publicUrl = getString(COLUMN_PUBLIC_URL),
rating = getFloat(COLUMN_RATING),
isNsfw = getBooleanOrDefault(COLUMN_IS_NSFW, false),
coverUrl = getString(COLUMN_COVER_URL),
tags = getStringOrNull(COLUMN_TAGS)?.split(':')?.mapNotNullToSet {
val parts = it.splitTwoParts('=') ?: return@mapNotNullToSet null
MangaTag(key = parts.first, title = parts.second, source = source)
}.orEmpty(),
state = getStringOrNull(COLUMN_STATE)?.let { MangaState.entries.find(it) },
author = getStringOrNull(COLUMN_AUTHOR),
largeCoverUrl = getStringOrNull(COLUMN_LARGE_COVER_URL),
description = getStringOrNull(COLUMN_DESCRIPTION),
chapters = emptyList(),
source = source,
)
private inline fun <R> runCatchingCompatibility(block: () -> R): R = try {
block()
} catch (e: NoSuchElementException) { // unknown column name
throw IncompatiblePluginException(source.name, e)
} catch (e: IllegalArgumentException) {
throw IncompatiblePluginException(source.name, e)
}
private fun Cursor?.safe() = SafeCursor(this ?: throw IncompatiblePluginException(source.name, null))
class MangaSourceCapabilities(
val availableSortOrders: Set<SortOrder>,
val availableStates: Set<MangaState>,
val availableContentRating: Set<ContentRating>,
val isMultipleTagsSupported: Boolean,
val isTagsExclusionSupported: Boolean,
val isSearchSupported: Boolean,
val contentType: ContentType,
val defaultSortOrder: SortOrder,
val sourceLocale: Locale,
)
private companion object {
const val COLUMN_SORT_ORDERS = "sort_orders"
const val COLUMN_STATES = "states"
const val COLUMN_CONTENT_RATING = "content_rating"
const val COLUMN_MULTIPLE_TAGS_SUPPORTED = "multiple_tags_supported"
const val COLUMN_TAGS_EXCLUSION_SUPPORTED = "tags_exclusion_supported"
const val COLUMN_SEARCH_SUPPORTED = "search_supported"
const val COLUMN_CONTENT_TYPE = "content_type"
const val COLUMN_DEFAULT_SORT_ORDER = "default_sort_order"
const val COLUMN_LOCALE = "locale"
const val COLUMN_ID = "id"
const val COLUMN_NAME = "name"
const val COLUMN_NUMBER = "number"
const val COLUMN_VOLUME = "volume"
const val COLUMN_URL = "url"
const val COLUMN_SCANLATOR = "scanlator"
const val COLUMN_UPLOAD_DATE = "upload_date"
const val COLUMN_BRANCH = "branch"
const val COLUMN_TITLE = "title"
const val COLUMN_ALT_TITLE = "alt_title"
const val COLUMN_PUBLIC_URL = "public_url"
const val COLUMN_RATING = "rating"
const val COLUMN_IS_NSFW = "is_nsfw"
const val COLUMN_COVER_URL = "cover_url"
const val COLUMN_TAGS = "tags"
const val COLUMN_STATE = "state"
const val COLUMN_AUTHOR = "author"
const val COLUMN_LARGE_COVER_URL = "large_cover_url"
const val COLUMN_DESCRIPTION = "description"
const val COLUMN_PREVIEW = "preview"
const val COLUMN_KEY = "key"
}
}

View File

@@ -0,0 +1,73 @@
package org.koitharu.kotatsu.core.parser.external
import android.database.Cursor
import android.database.CursorWrapper
import org.koitharu.kotatsu.core.util.ext.getBoolean
class SafeCursor(cursor: Cursor) : CursorWrapper(cursor) {
fun getString(columnName: String): String {
return getString(getColumnIndexOrThrow(columnName))
}
fun getStringOrNull(columnName: String): String? {
val columnIndex = getColumnIndex(columnName)
return when {
columnIndex < 0 -> null
isNull(columnIndex) -> null
else -> getString(columnIndex)
}
}
fun getBoolean(columnName: String): Boolean {
return getBoolean(getColumnIndexOrThrow(columnName))
}
fun getBooleanOrDefault(columnName: String, defaultValue: Boolean): Boolean {
val columnIndex = getColumnIndex(columnName)
return when {
columnIndex < 0 -> defaultValue
isNull(columnIndex) -> defaultValue
else -> getBoolean(columnIndex)
}
}
fun getInt(columnName: String): Int {
return getInt(getColumnIndexOrThrow(columnName))
}
fun getIntOrDefault(columnName: String, defaultValue: Int): Int {
val columnIndex = getColumnIndex(columnName)
return when {
columnIndex < 0 -> defaultValue
isNull(columnIndex) -> defaultValue
else -> getInt(columnIndex)
}
}
fun getLong(columnName: String): Long {
return getLong(getColumnIndexOrThrow(columnName))
}
fun getLongOrDefault(columnName: String, defaultValue: Long): Long {
val columnIndex = getColumnIndex(columnName)
return when {
columnIndex < 0 -> defaultValue
isNull(columnIndex) -> defaultValue
else -> getLong(columnIndex)
}
}
fun getFloat(columnName: String): Float {
return getFloat(getColumnIndexOrThrow(columnName))
}
fun getFloatOrDefault(columnName: String, defaultValue: Float): Float {
val columnIndex = getColumnIndex(columnName)
return when {
columnIndex < 0 -> defaultValue
isNull(columnIndex) -> defaultValue
else -> getFloat(columnIndex)
}
}
}

View File

@@ -1,12 +1,19 @@
package org.koitharu.kotatsu.core.parser.favicon
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.AdaptiveIconDrawable
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.net.Uri
import android.os.Build
import android.webkit.MimeTypeMap
import coil.ImageLoader
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.disk.DiskCache
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
@@ -14,7 +21,9 @@ import coil.network.HttpException
import coil.request.Options
import coil.size.Size
import coil.size.pxOrElse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -24,8 +33,10 @@ import okio.Closeable
import okio.buffer
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.util.ext.requireBody
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.local.data.CacheDir
@@ -53,7 +64,20 @@ class FaviconFetcher(
override suspend fun fetch(): FetchResult {
getCached(options)?.let { return it }
val repo = mangaRepositoryFactory.create(mangaSource) as RemoteMangaRepository
return when (val repo = mangaRepositoryFactory.create(mangaSource)) {
is ParserMangaRepository -> fetchParserFavicon(repo)
is ExternalMangaRepository -> fetchPluginIcon(repo)
is EmptyMangaRepository -> DrawableResult(
drawable = ColorDrawable(Color.WHITE),
isSampled = false,
dataSource = DataSource.MEMORY,
)
else -> throw IllegalArgumentException("")
}
}
private suspend fun fetchParserFavicon(repo: ParserMangaRepository): FetchResult {
val sizePx = maxOf(
options.size.width.pxOrElse { FALLBACK_SIZE },
options.size.height.pxOrElse { FALLBACK_SIZE },
@@ -100,6 +124,20 @@ class FaviconFetcher(
return response
}
private suspend fun fetchPluginIcon(repository: ExternalMangaRepository): FetchResult {
val source = repository.source
val pm = options.context.packageManager
val icon = runInterruptible(Dispatchers.IO) {
val provider = pm.resolveContentProvider(source.authority, 0)
provider?.loadIcon(pm) ?: pm.getApplicationIcon(source.packageName)
}
return DrawableResult(
drawable = icon.nonAdaptive(),
isSampled = false,
dataSource = DataSource.DISK,
)
}
private fun getCached(options: Options): SourceResult? {
if (!options.diskCachePolicy.readEnabled) {
return null
@@ -165,6 +203,13 @@ class FaviconFetcher(
}
}
private fun Drawable.nonAdaptive() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this is AdaptiveIconDrawable) {
LayerDrawable(arrayOf(background, foreground))
} else {
this
}
class Factory(
context: Context,
okHttpClientLazy: Lazy<OkHttpClient>,

View File

@@ -33,7 +33,6 @@ import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import java.io.File
import java.net.Proxy
import java.util.EnumSet
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@@ -193,8 +192,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_FEED_HEADER, true)
set(value) = prefs.edit { putBoolean(KEY_FEED_HEADER, value) }
val isReadingIndicatorsEnabled: Boolean
get() = prefs.getBoolean(KEY_READING_INDICATORS, true)
val progressIndicatorMode: ProgressIndicatorMode
get() = prefs.getEnumValue(KEY_PROGRESS_INDICATORS, ProgressIndicatorMode.PERCENT_READ)
val isHistoryExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_HISTORY_EXCLUDE_NSFW, false)
@@ -485,6 +484,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isAutoLocalChaptersCleanupEnabled: Boolean
get() = prefs.getBoolean(KEY_CHAPTERS_CLEAR_AUTO, false)
fun isPagesCropEnabled(mode: ReaderMode): Boolean {
val rawValue = prefs.getStringSet(KEY_READER_CROP, emptySet())
if (rawValue.isNullOrEmpty()) {
return false
}
val needle = if (mode == ReaderMode.WEBTOON) READER_CROP_WEBTOON else READER_CROP_PAGED
return needle.toString() in rawValue
}
fun isTipEnabled(tip: String): Boolean {
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
}
@@ -597,6 +605,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_ANIMATION = "reader_animation2"
const val KEY_READER_MODE = "reader_mode"
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
const val KEY_READER_CROP = "reader_crop"
const val KEY_APP_PASSWORD = "app_password"
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
const val KEY_PROTECT_APP = "protect_app"
@@ -610,7 +619,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_UPDATED_GROUPING = "updated_grouping"
const val KEY_READING_INDICATORS = "reading_indicators"
const val KEY_PROGRESS_INDICATORS = "progress_indicators"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
const val KEY_GRID_VIEW_CHAPTERS = "grid_view_chapters"
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
@@ -695,8 +704,13 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LOGS_SHARE = "logs_share"
const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_TRANSLATION = "about_app_translation"
const val PROXY_TEST = "proxy_test"
// old keys are for migration only
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"
// values
private const val READER_CROP_PAGED = 1
private const val READER_CROP_WEBTOON = 2
}
}

View File

@@ -0,0 +1,6 @@
package org.koitharu.kotatsu.core.prefs
enum class ProgressIndicatorMode {
NONE, PERCENT_READ, PERCENT_LEFT, CHAPTERS_READ, CHAPTERS_LEFT;
}

View File

@@ -12,5 +12,6 @@ enum class SearchSuggestionType(
QUERIES_SUGGEST(R.string.suggested_queries),
MANGA(R.string.content_type_manga),
SOURCES(R.string.remote_sources),
RECENT_SOURCES(R.string.recent_sources),
AUTHORS(R.string.authors),
}

View File

@@ -38,6 +38,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
is ConfigKey.PreferredImageServer -> prefs.getString(key.key, key.defaultValue)?.takeUnless(String::isEmpty)
} as T
}
@@ -47,6 +48,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
is ConfigKey.UserAgent -> putString(key.key, (value as String?)?.sanitizeHeaderValue())
is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean)
is ConfigKey.PreferredImageServer -> putString(key.key, value as String? ?: "")
}
}

View File

@@ -0,0 +1,72 @@
package org.koitharu.kotatsu.core.ui.image
import android.animation.TimeAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.drawable.Animatable
import androidx.annotation.StyleRes
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import com.google.android.material.animation.ArgbEvaluatorCompat
import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import kotlin.math.abs
class AnimatedFaviconDrawable(
context: Context,
@StyleRes styleResId: Int,
name: String,
) : FaviconDrawable(context, styleResId, name), Animatable, TimeAnimator.TimeListener {
private val interpolator = FastOutSlowInInterpolator()
private val period = context.getAnimationDuration(R.integer.config_longAnimTime) * 2
private val timeAnimator = TimeAnimator()
private val colorHigh = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground)
private val colorLow = ArgbEvaluatorCompat.getInstance().evaluate(0.3f, colorHigh, colorBackground)
init {
timeAnimator.setTimeListener(this)
updateColor()
}
override fun draw(canvas: Canvas) {
if (!isRunning && period > 0) {
updateColor()
start()
}
super.draw(canvas)
}
override fun setAlpha(alpha: Int) = Unit
override fun getAlpha(): Int = 255
override fun onTimeUpdate(animation: TimeAnimator?, totalTime: Long, deltaTime: Long) {
callback?.also {
updateColor()
it.invalidateDrawable(this)
} ?: stop()
}
override fun start() {
timeAnimator.start()
}
override fun stop() {
timeAnimator.end()
}
override fun isRunning(): Boolean = timeAnimator.isStarted
private fun updateColor() {
if (period <= 0f) {
return
}
val ph = period / 2
val fraction = abs((System.currentTimeMillis() % period) - ph) / ph.toFloat()
colorForeground = ArgbEvaluatorCompat.getInstance()
.evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh)
}
}

View File

@@ -17,18 +17,18 @@ import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.KotatsuColors
class FaviconDrawable(
open class FaviconDrawable(
context: Context,
@StyleRes styleResId: Int,
name: String,
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private var colorBackground = Color.WHITE
protected var colorBackground = Color.WHITE
protected var colorForeground = Color.DKGRAY
private var colorStroke = Color.LTGRAY
private val letter = name.take(1).uppercase()
private var cornerSize = 0f
private var colorForeground = Color.DKGRAY
private val textBounds = Rect()
private val tempRect = Rect()
private val boundsF = RectF()

View File

@@ -1,15 +1,10 @@
package org.koitharu.kotatsu.core.ui.image
import android.graphics.Bitmap
import androidx.annotation.ColorInt
import androidx.core.graphics.alpha
import androidx.core.graphics.blue
import androidx.core.graphics.get
import androidx.core.graphics.green
import androidx.core.graphics.red
import coil.size.Size
import coil.transform.Transformation
import kotlin.math.abs
import org.koitharu.kotatsu.reader.domain.EdgeDetector.Companion.isColorTheSame
class TrimTransformation(
private val tolerance: Int = 20,
@@ -28,7 +23,7 @@ class TrimTransformation(
var isColBlank = true
val prevColor = input[x, 0]
for (y in 1 until input.height) {
if (!isColorTheSame(input[x, y], prevColor)) {
if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
isColBlank = false
break
}
@@ -47,7 +42,7 @@ class TrimTransformation(
var isColBlank = true
val prevColor = input[x, 0]
for (y in 1 until input.height) {
if (!isColorTheSame(input[x, y], prevColor)) {
if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
isColBlank = false
break
}
@@ -63,7 +58,7 @@ class TrimTransformation(
var isRowBlank = true
val prevColor = input[0, y]
for (x in 1 until input.width) {
if (!isColorTheSame(input[x, y], prevColor)) {
if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
isRowBlank = false
break
}
@@ -79,7 +74,7 @@ class TrimTransformation(
var isRowBlank = true
val prevColor = input[0, y]
for (x in 1 until input.width) {
if (!isColorTheSame(input[x, y], prevColor)) {
if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
isRowBlank = false
break
}
@@ -98,13 +93,6 @@ class TrimTransformation(
}
}
private fun isColorTheSame(@ColorInt a: Int, @ColorInt b: Int): Boolean {
return abs(a.red - b.red) <= tolerance &&
abs(a.green - b.green) <= tolerance &&
abs(a.blue - b.blue) <= tolerance &&
abs(a.alpha - b.alpha) <= tolerance
}
override fun equals(other: Any?): Boolean {
return this === other || (other is TrimTransformation && other.tolerance == tolerance)
}

View File

@@ -1,11 +1,11 @@
package org.koitharu.kotatsu.core.ui.list
import android.app.Notification.Action
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.view.ActionMode
import androidx.collection.LongSet
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
@@ -14,6 +14,8 @@ import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryOwner
import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.core.util.ext.toLongArray
import org.koitharu.kotatsu.core.util.ext.toSet
import kotlin.coroutines.EmptyCoroutineContext
private const val KEY_SELECTION = "selection"
@@ -35,11 +37,9 @@ class ListSelectionController(
registryOwner.lifecycle.addObserver(StateEventObserver())
}
fun snapshot(): Set<Long> {
return peekCheckedIds().toSet()
}
fun snapshot(): Set<Long> = peekCheckedIds().toSet()
fun peekCheckedIds(): Set<Long> {
fun peekCheckedIds(): LongSet {
return decoration.checkedItemsIds
}

View File

@@ -4,6 +4,8 @@ import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.RectF
import android.view.View
import androidx.collection.LongSet
import androidx.collection.MutableLongSet
import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID
@@ -12,7 +14,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
private val bounds = Rect()
private val boundsF = RectF()
protected val selection = HashSet<Long>()
protected val selection = MutableLongSet()
protected var hasBackground: Boolean = true
protected var hasForeground: Boolean = false
@@ -21,7 +23,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
val checkedItemsCount: Int
get() = selection.size
val checkedItemsIds: Set<Long>
val checkedItemsIds: LongSet
get() = selection
fun toggleItemChecked(id: Long) {
@@ -39,7 +41,9 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
}
fun checkAll(ids: Collection<Long>) {
selection.addAll(ids)
for (id in ids) {
selection.add(id)
}
}
fun clearSelection() {

View File

@@ -23,11 +23,16 @@ class SelectableTextView @JvmOverloads constructor(
private fun fixSelectionRange() {
if (selectionStart < 0 || selectionEnd < 0) {
val spannableText = text as? Spannable ?: return
Selection.setSelection(spannableText, text.length)
Selection.setSelection(spannableText, spannableText.length)
}
}
override fun scrollTo(x: Int, y: Int) {
super.scrollTo(0, 0)
}
fun selectAll() {
val spannableText = text as? Spannable ?: return
Selection.selectAll(spannableText)
}
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.util.ext
import androidx.collection.ArrayMap
import androidx.collection.ArraySet
import androidx.collection.LongSet
import org.koitharu.kotatsu.BuildConfig
import java.util.Collections
import java.util.EnumSet
@@ -69,4 +70,24 @@ fun <T> Iterable<T>.sortedWithSafe(comparator: Comparator<in T>): List<T> = try
}
}
fun Collection<*>?.sizeOrZero() = if (this == null) 0 else size
fun Collection<*>?.sizeOrZero() = this?.size ?: 0
@Suppress("UNCHECKED_CAST")
inline fun <T, reified R> Collection<T>.mapToArray(transform: (T) -> R): Array<R> {
val result = arrayOfNulls<R>(size)
forEachIndexed { index, t -> result[index] = transform(t) }
return result as Array<R>
}
fun LongSet.toLongArray(): LongArray {
val result = LongArray(size)
var i = 0
forEach { result[i++] = it }
return result
}
fun LongSet.toSet(): Set<Long> = toCollection(ArraySet<Long>(size))
fun <R : MutableCollection<Long>> LongSet.toCollection(out: R): R = out.also { result ->
forEach(result::add)
}

View File

@@ -12,12 +12,16 @@ import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.plus
import kotlinx.coroutines.suspendCancellableCoroutine
import org.koitharu.kotatsu.core.util.AcraCoroutineErrorHandler
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.parsers.util.cancelAll
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -90,3 +94,10 @@ fun <T> Deferred<T>.peek(): T? = if (isCompleted) {
} else {
null
}
@Suppress("SuspendFunctionOnCoroutineScope")
suspend fun CoroutineScope.cancelChildrenAndJoin(cause: CancellationException? = null) {
val jobs = coroutineContext[Job]?.children?.toList() ?: return
jobs.cancelAll(cause)
jobs.joinAll()
}

View File

@@ -37,3 +37,5 @@ fun JSONObject.toContentValues(): ContentValues {
}
private fun String.escapeName() = "`$this`"
fun Cursor.getBoolean(columnIndex: Int) = getInt(columnIndex) > 0

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.util.ext
import android.graphics.Bitmap
import android.graphics.Rect
import kotlin.math.roundToInt
@@ -11,3 +12,9 @@ fun Rect.scale(factor: Double) {
(height() - newHeight) / 2,
)
}
inline fun <R> Bitmap.use(block: (Bitmap) -> R) = try {
block(this)
} finally {
recycle()
}

View File

@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.exceptions.CaughtException
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
import org.koitharu.kotatsu.core.exceptions.SyncApiException
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
@@ -60,7 +61,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
-> resources.getString(R.string.network_error)
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
is IncompatiblePluginException -> resources.getString(R.string.plugin_incompatible)
is WrongPasswordException -> resources.getString(R.string.wrong_password)
is NotFoundException -> resources.getString(R.string.not_found_404)
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)

View File

@@ -55,11 +55,11 @@ class DetailsLoadUseCase @Inject constructor(
try {
val details = getDetails(manga)
launch { updateTracker(details) }
send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false), false))
send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true), true))
send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false)?.trim(), false))
send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true)?.trim(), true))
} catch (e: IOException) {
local?.await()?.manga?.also { localManga ->
send(MangaDetails(localManga, null, localManga.description?.parseAsHtml(withImages = false), true))
send(MangaDetails(localManga, null, localManga.description?.parseAsHtml(withImages = false)?.trim(), true))
} ?: close(e)
}
}

View File

@@ -89,11 +89,11 @@ import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
import org.koitharu.kotatsu.parsers.model.Manga
@@ -122,10 +122,9 @@ class DetailsActivity :
lateinit var coil: ImageLoader
@Inject
lateinit var tagHighlighter: ListExtraProvider
lateinit var listMapper: MangaListMapper
private val viewModel: DetailsViewModel by viewModels()
private lateinit var menuProvider: DetailsMenuProvider
override fun onCreate(savedInstanceState: Bundle?) {
@@ -159,6 +158,7 @@ class DetailsActivity :
viewBinding.containerBottomSheet?.let { sheet ->
onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(sheet))
}
TitleExpandListener(viewBinding.textViewTitle).attach()
viewModel.details.filterNotNull().observe(this, ::onMangaUpdated)
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
@@ -391,7 +391,7 @@ class DetailsActivity :
}
}
private fun onRelatedMangaChanged(related: List<MangaItemModel>) {
private fun onRelatedMangaChanged(related: List<MangaListModel>) {
if (related.isEmpty()) {
viewBinding.groupRelated.isVisible = false
return
@@ -613,15 +613,7 @@ class DetailsActivity :
private fun bindTags(manga: Manga) {
viewBinding.chipsTags.isVisible = manga.tags.isNotEmpty()
viewBinding.chipsTags.setChips(
manga.tags.map { tag ->
ChipsView.ChipModel(
title = tag.title,
tint = tagHighlighter.getTagTint(tag),
data = tag,
)
},
)
viewBinding.chipsTags.setChips(listMapper.mapTags(manga.tags))
}
private fun loadCover(manga: Manga) {

View File

@@ -50,9 +50,8 @@ import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
import org.koitharu.kotatsu.local.domain.model.LocalManga
@@ -76,7 +75,7 @@ class DetailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
private val relatedMangaUseCase: RelatedMangaUseCase,
private val extraProvider: ListExtraProvider,
private val mangaListMapper: MangaListMapper,
private val detailsLoadUseCase: DetailsLoadUseCase,
private val progressUpdateUseCase: ProgressUpdateUseCase,
private val readingTimeUseCase: ReadingTimeUseCase,
@@ -171,9 +170,12 @@ class DetailsViewModel @Inject constructor(
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val relatedManga: StateFlow<List<MangaItemModel>> = manga.mapLatest {
val relatedManga: StateFlow<List<MangaListModel>> = manga.mapLatest {
if (it != null && settings.isRelatedMangaEnabled) {
relatedMangaUseCase.invoke(it)?.toUi(ListMode.GRID, extraProvider).orEmpty()
mangaListMapper.toListModelList(
manga = relatedMangaUseCase(it).orEmpty(),
mode = ListMode.GRID,
)
} else {
emptyList()
}

View File

@@ -0,0 +1,45 @@
package org.koitharu.kotatsu.details.ui
import android.annotation.SuppressLint
import android.transition.TransitionManager
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.view.View.OnTouchListener
import android.view.ViewGroup
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.widgets.SelectableTextView
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
@SuppressLint("ClickableViewAccessibility")
class TitleExpandListener(
private val textView: SelectableTextView,
) : GestureDetector.SimpleOnGestureListener(), OnTouchListener {
private val gestureDetector = GestureDetector(textView.context, this)
private val linesExpanded = textView.resources.getInteger(R.integer.details_description_lines)
private val linesCollapsed = textView.resources.getInteger(R.integer.details_title_lines)
override fun onTouch(v: View?, event: MotionEvent) = gestureDetector.onTouchEvent(event)
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
if (textView.context.isAnimationsEnabled) {
TransitionManager.beginDelayedTransition(textView.parent as ViewGroup)
}
if (textView.maxLines in 1 until Integer.MAX_VALUE) {
textView.maxLines = Integer.MAX_VALUE
} else {
textView.maxLines = linesCollapsed
}
return true
}
override fun onLongPress(e: MotionEvent) {
textView.maxLines = Integer.MAX_VALUE
textView.selectAll()
}
fun attach() {
textView.setOnTouchListener(this)
}
}

View File

@@ -31,7 +31,11 @@ fun HistoryInfo(
history: MangaHistory?,
isIncognitoMode: Boolean
): HistoryInfo {
val chapters = manga?.chapters?.get(branch)
val chapters = if (manga?.chapters?.isEmpty() == true) {
emptyList()
} else {
manga?.chapters?.get(branch)
}
val currentChapter = if (history != null && !chapters.isNullOrEmpty()) {
chapters.indexOfFirst { it.id == history.chapterId }
} else {

View File

@@ -32,6 +32,8 @@ import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.toCollection
import org.koitharu.kotatsu.core.util.ext.toSet
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
@@ -137,10 +139,10 @@ class ChaptersFragment :
val ids = selectionController?.peekCheckedIds()
val manga = viewModel.manga.value
when {
ids.isNullOrEmpty() || manga == null -> Unit
ids == null || ids.isEmpty() || manga == null -> Unit
ids.size == manga.chapters?.size -> viewModel.deleteLocal()
else -> {
LocalChaptersRemoveService.start(requireContext(), manga, ids)
LocalChaptersRemoveService.start(requireContext(), manga, ids.toSet())
Snackbar.make(
requireViewBinding().recyclerViewChapters,
R.string.chapters_will_removed_background,
@@ -154,7 +156,7 @@ class ChaptersFragment :
R.id.action_select_range -> {
val items = chaptersAdapter?.items ?: return false
val ids = HashSet(controller.peekCheckedIds())
val ids = controller.peekCheckedIds().toCollection(HashSet())
val buffer = HashSet<Long>()
var isAdding = false
for (x in items) {
@@ -188,8 +190,12 @@ class ChaptersFragment :
}
R.id.action_mark_current -> {
val id = controller.peekCheckedIds().singleOrNull() ?: return false
viewModel.markChapterAsCurrent(id)
val ids = controller.peekCheckedIds()
if (ids.size == 1) {
viewModel.markChapterAsCurrent(ids.first())
} else {
return false
}
mode.finish()
true
}

View File

@@ -92,7 +92,7 @@ class MangaPageFetcher(
}
else -> {
val request = PageLoader.createPageRequest(page, pageUrl)
val request = PageLoader.createPageRequest(pageUrl, page.source)
imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response ->
if (!response.isSuccessful) {
throw HttpException(response)

View File

@@ -20,12 +20,11 @@ import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject
@@ -34,7 +33,7 @@ class RelatedListViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory,
settings: AppSettings,
private val extraProvider: ListExtraProvider,
private val mangaListMapper: MangaListMapper,
downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler) {
@@ -46,14 +45,14 @@ class RelatedListViewModel @Inject constructor(
override val content = combine(
mangaList,
listMode,
observeListModeWithTriggers(),
listError,
) { list, mode, error ->
when {
list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true))
list == null -> listOf(LoadingState)
list.isEmpty() -> listOf(createEmptyState())
else -> list.toUi(mode, extraProvider)
else -> mangaListMapper.toListModelList(list, mode)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.download.ui.list
import androidx.collection.ArrayMap
import androidx.collection.LongSet
import androidx.collection.LongSparseArray
import androidx.collection.getOrElse
import androidx.collection.set
@@ -24,7 +25,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.formatNumber
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
@@ -182,7 +183,7 @@ class DownloadsViewModel @Inject constructor(
}
}
fun snapshot(ids: Set<Long>): Collection<DownloadItemModel> {
fun snapshot(ids: LongSet): Collection<DownloadItemModel> {
return works.value?.filterTo(ArrayList(ids.size)) { x -> x.id.mostSignificantBits in ids }.orEmpty()
}
@@ -325,6 +326,6 @@ class DownloadsViewModel @Inject constructor(
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
private suspend fun tryLoad(manga: Manga) = runCatchingCancellable {
(mangaRepositoryFactory.create(manga.source) as RemoteMangaRepository).getDetails(manga)
(mangaRepositoryFactory.create(manga.source) as ParserMangaRepository).getDetails(manga)
}.getOrNull()
}

View File

@@ -3,7 +3,7 @@ package org.koitharu.kotatsu.download.ui.worker
import androidx.collection.MutableObjectLongMap
import kotlinx.coroutines.delay
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaSource
class DownloadSlowdownDispatcher(
@@ -13,7 +13,7 @@ class DownloadSlowdownDispatcher(
private val timeMap = MutableObjectLongMap<MangaSource>()
suspend fun delay(source: MangaSource) {
val repo = mangaRepositoryFactory.create(source) as? RemoteMangaRepository ?: return
val repo = mangaRepositoryFactory.create(source) as? ParserMangaRepository ?: return
if (!repo.isSlowdownEnabled()) {
return
}

View File

@@ -35,17 +35,17 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.internal.closeQuietly
import okio.IOException
import okio.buffer
import okio.sink
import okio.use
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -56,6 +56,7 @@ import org.koitharu.kotatsu.core.util.ext.awaitWorkInfosByTag
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.deleteWork
import org.koitharu.kotatsu.core.util.ext.deleteWorks
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getWorkInputData
import org.koitharu.kotatsu.core.util.ext.getWorkSpec
@@ -74,9 +75,9 @@ import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.domain.PageLoader
import java.io.File
import java.util.UUID
import java.util.concurrent.TimeUnit
@@ -94,6 +95,7 @@ class DownloadWorker @AssistedInject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val settings: AppSettings,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
private val imageProxyInterceptor: ImageProxyInterceptor,
notificationFactoryFactory: DownloadNotificationFactory.Factory,
) : CoroutineWorker(appContext, params) {
@@ -328,28 +330,24 @@ class DownloadWorker @AssistedInject constructor(
destination: File,
source: MangaSource,
): File {
val request = Request.Builder()
.url(url)
.tag(MangaSource::class.java, source)
.header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8")
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
.get()
.build()
val request = PageLoader.createPageRequest(url, source)
slowdownDispatcher.delay(source)
val call = okHttp.newCall(request)
val file = File(destination, UUID.randomUUID().toString() + ".tmp")
try {
val response = call.clone().await()
checkNotNull(response.body).use { body ->
file.sink(append = false).buffer().use {
it.writeAllCancellable(body.source())
return imageProxyInterceptor.interceptPageRequest(request, okHttp)
.ensureSuccess()
.use { response ->
val file = File(destination, UUID.randomUUID().toString() + ".tmp")
try {
checkNotNull(response.body).use { body ->
file.sink(append = false).buffer().use {
it.writeAllCancellable(body.source())
}
}
} catch (e: CancellationException) {
file.delete()
throw e
}
file
}
} catch (e: CancellationException) {
file.delete()
throw e
}
return file
}
private suspend fun publishState(state: DownloadState) {

View File

@@ -1,8 +1,16 @@
package org.koitharu.kotatsu.explore.data
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
import androidx.room.withTransaction
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
@@ -12,21 +20,27 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet
import java.util.Collections
import java.util.EnumSet
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import javax.inject.Singleton
@Reusable
@Singleton
class MangaSourcesRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val db: MangaDatabase,
private val settings: AppSettings,
) {
@@ -47,7 +61,26 @@ class MangaSourcesRepository @Inject constructor(
suspend fun getEnabledSources(): List<MangaSource> {
assimilateNewSources()
val order = settings.sourcesSortOrder
return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order)
return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order).let { enabled ->
val external = getExternalSources()
val list = ArrayList<MangaSourceInfo>(enabled.size + external.size)
external.mapTo(list) { MangaSourceInfo(it, isEnabled = true, isPinned = true) }
list.addAll(enabled)
list
}
}
suspend fun getPinnedSources(): Set<MangaSource> {
assimilateNewSources()
val skipNsfw = settings.isNsfwContentDisabled
return dao.findAllPinned().mapNotNullToSet {
it.source.toMangaSourceOrNull()?.takeUnless { x -> skipNsfw && x.isNsfw() }
}
}
suspend fun getTopSources(limit: Int): List<MangaSource> {
assimilateNewSources()
return dao.findLastUsed(limit).toSources(settings.isNsfwContentDisabled, null)
}
suspend fun getDisabledSources(): Set<MangaSource> {
@@ -61,7 +94,7 @@ class MangaSourcesRepository @Inject constructor(
return result
}
suspend fun getAvailableSources(
suspend fun queryParserSources(
isDisabledOnly: Boolean,
isNewOnly: Boolean,
excludeBroken: Boolean,
@@ -81,7 +114,9 @@ class MangaSourcesRepository @Inject constructor(
val sources = entities.toSources(
skipNsfwSources = settings.isNsfwContentDisabled,
sortOrder = sortOrder,
)
).run {
mapNotNullTo(ArrayList(size)) { it.mangaSource as? MangaParserSource }
}
if (locale != null) {
sources.retainAll { it.locale == locale }
}
@@ -93,7 +128,7 @@ class MangaSourcesRepository @Inject constructor(
}
if (!query.isNullOrEmpty()) {
sources.retainAll {
it.title.contains(query, ignoreCase = true) || it.name.contains(query, ignoreCase = true)
it.getTitle(context).contains(query, ignoreCase = true) || it.name.contains(query, ignoreCase = true)
}
}
return sources
@@ -126,14 +161,21 @@ class MangaSourcesRepository @Inject constructor(
}.distinctUntilChanged().onStart { assimilateNewSources() }
}
fun observeEnabledSources(): Flow<List<MangaSource>> = combine(
fun observeEnabledSources(): Flow<List<MangaSourceInfo>> = combine(
observeIsNsfwDisabled(),
observeSortOrder(),
) { skipNsfw, order ->
dao.observeEnabled(order).map {
it.toSources(skipNsfw, order)
}
}.flatMapLatest { it }.onStart { assimilateNewSources() }
}.flatMapLatest { it }
.onStart { assimilateNewSources() }
.combine(observeExternalSources()) { enabled, external ->
val list = ArrayList<MangaSourceInfo>(enabled.size + external.size)
external.mapTo(list) { MangaSourceInfo(it, isEnabled = true, isPinned = true) }
list.addAll(enabled)
list
}
fun observeAll(): Flow<List<Pair<MangaSource, Boolean>>> = dao.observeAll().map { entities ->
val result = ArrayList<Pair<MangaSource, Boolean>>(entities.size)
@@ -213,6 +255,8 @@ class MangaSourcesRepository @Inject constructor(
isEnabled = false,
sortKey = ++maxSortKey,
addedIn = BuildConfig.VERSION_CODE,
lastUsedAt = 0,
isPinned = false,
)
}
dao.insertIfAbsent(entities)
@@ -223,6 +267,19 @@ class MangaSourcesRepository @Inject constructor(
return settings.sourcesVersion == 0 && dao.findAllEnabledNames().isEmpty()
}
suspend fun setIsPinned(sources: Collection<MangaSource>, isPinned: Boolean): ReversibleHandle {
setSourcesPinnedImpl(sources, isPinned)
return ReversibleHandle {
setSourcesEnabledImpl(sources, !isPinned)
}
}
suspend fun trackUsage(source: MangaSource) {
if (!settings.isIncognitoModeEnabled && !(settings.isHistoryExcludeNsfw && source.isNsfw())) {
dao.setLastUsed(source.name, System.currentTimeMillis())
}
}
private suspend fun setSourcesEnabledImpl(sources: Collection<MangaSource>, isEnabled: Boolean) {
if (sources.size == 1) { // fast path
dao.setEnabled(sources.first().name, isEnabled)
@@ -244,22 +301,77 @@ class MangaSourcesRepository @Inject constructor(
return result
}
private suspend fun setSourcesPinnedImpl(sources: Collection<MangaSource>, isPinned: Boolean) {
if (sources.size == 1) { // fast path
dao.setPinned(sources.first().name, isPinned)
return
}
db.withTransaction {
for (source in sources) {
dao.setPinned(source.name, isPinned)
}
}
}
private fun observeExternalSources(): Flow<List<ExternalMangaSource>> {
return callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
trySendBlocking(intent)
}
}
ContextCompat.registerReceiver(
context,
receiver,
IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_VERIFIED)
addAction(Intent.ACTION_PACKAGE_REPLACED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
addDataScheme("package")
},
ContextCompat.RECEIVER_EXPORTED,
)
awaitClose { context.unregisterReceiver(receiver) }
}.onStart {
emit(null)
}.map {
getExternalSources()
}.distinctUntilChanged()
}
private fun getExternalSources() = context.packageManager.queryIntentContentProviders(
Intent("app.kotatsu.parser.PROVIDE_MANGA"), 0,
).map { resolveInfo ->
ExternalMangaSource(
packageName = resolveInfo.providerInfo.packageName,
authority = resolveInfo.providerInfo.authority,
)
}
private fun List<MangaSourceEntity>.toSources(
skipNsfwSources: Boolean,
sortOrder: SourcesSortOrder?,
): MutableList<MangaParserSource> {
val result = ArrayList<MangaParserSource>(size)
): MutableList<MangaSourceInfo> {
val result = ArrayList<MangaSourceInfo>(size)
for (entity in this) {
val source = entity.source.toMangaSourceOrNull() ?: continue
if (skipNsfwSources && source.isNsfw()) {
continue
}
if (source in remoteSources) {
result.add(source)
result.add(
MangaSourceInfo(
mangaSource = source,
isEnabled = entity.isEnabled,
isPinned = entity.isPinned,
),
)
}
}
if (sortOrder == SourcesSortOrder.ALPHABETIC) {
result.sortBy { it.title }
result.sortWith(compareBy<MangaSourceInfo> { !it.isPinned }.thenBy { it.getTitle(context) })
}
return result
}

View File

@@ -9,4 +9,5 @@ enum class SourcesSortOrder(
ALPHABETIC(R.string.by_name),
POPULARITY(R.string.popular),
MANUAL(R.string.manual),
LAST_USED(R.string.last_used),
}

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.explore.ui
import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
@@ -21,6 +23,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
@@ -42,7 +45,6 @@ import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
@@ -166,16 +168,19 @@ class ExploreFragment :
}
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
val isSingleSelection = controller.count == 1
val selectedSources = viewModel.sourcesSnapshot(controller.peekCheckedIds())
val isSingleSelection = selectedSources.size == 1
menu.findItem(R.id.action_settings).isVisible = isSingleSelection
menu.findItem(R.id.action_shortcut).isVisible = isSingleSelection
menu.findItem(R.id.action_pin).isVisible = selectedSources.all { !it.isPinned }
menu.findItem(R.id.action_unpin).isVisible = selectedSources.all { it.isPinned }
menu.findItem(R.id.action_disable)?.isVisible = selectedSources.all { it.mangaSource is MangaParserSource }
menu.findItem(R.id.action_delete)?.isVisible = selectedSources.all { it.mangaSource is ExternalMangaSource }
return super.onPrepareActionMode(controller, mode, menu)
}
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
val selectedSources = controller.peekCheckedIds().mapNotNullToSet { id ->
MangaParserSource.entries.getOrNull(id.toInt()) // TODO
}
val selectedSources = viewModel.sourcesSnapshot(controller.peekCheckedIds())
if (selectedSources.isEmpty()) {
return false
}
@@ -191,12 +196,29 @@ class ExploreFragment :
mode.finish()
}
R.id.action_delete -> {
selectedSources.forEach {
(it.mangaSource as? ExternalMangaSource)?.let { uninstallExternalSource(it) }
}
mode.finish()
}
R.id.action_shortcut -> {
val source = selectedSources.singleOrNull() ?: return false
viewModel.requestPinShortcut(source)
mode.finish()
}
R.id.action_pin -> {
viewModel.setSourcesPinned(selectedSources, isPinned = true)
mode.finish()
}
R.id.action_unpin -> {
viewModel.setSourcesPinned(selectedSources, isPinned = false)
mode.finish()
}
else -> return false
}
return true
@@ -229,4 +251,14 @@ class ExploreFragment :
.create()
.show()
}
private fun uninstallExternalSource(source: ExternalMangaSource) {
val uri = Uri.fromParts("package", source.packageName, null)
val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Intent.ACTION_DELETE
} else {
Intent.ACTION_UNINSTALL_PACKAGE
}
context?.startActivity(Intent(action, uri))
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.explore.ui
import androidx.collection.LongSet
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
@@ -13,6 +14,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
@@ -26,12 +28,11 @@ import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.explore.ui.model.ExploreButtons
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
import org.koitharu.kotatsu.explore.ui.model.RecommendationsItem
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.model.EmptyHint
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -108,11 +109,29 @@ class ExploreViewModel @Inject constructor(
}
}
fun setSourcesPinned(sources: Collection<MangaSource>, isPinned: Boolean) {
launchJob(Dispatchers.Default) {
sourcesRepository.setIsPinned(sources, isPinned)
val message = if (sources.size == 1) {
if (isPinned) R.string.source_pinned else R.string.source_unpinned
} else {
if (isPinned) R.string.sources_pinned else R.string.sources_unpinned
}
onActionDone.call(ReversibleAction(message, null))
}
}
fun respondSuggestionTip(isAccepted: Boolean) {
settings.isSuggestionsEnabled = isAccepted
settings.closeTip(TIP_SUGGESTIONS)
}
fun sourcesSnapshot(ids: LongSet): List<MangaSourceInfo> {
return content.value.mapNotNull {
(it as? MangaSourceItem)?.takeIf { x -> x.id in ids }?.source
}
}
private fun createContentFlow() = combine(
sourcesRepository.observeEnabledSources(),
getSuggestionFlow(),
@@ -124,7 +143,7 @@ class ExploreViewModel @Inject constructor(
}.withErrorHandling()
private fun buildList(
sources: List<MangaSource>,
sources: List<MangaSourceInfo>,
recommendation: List<Manga>,
isGrid: Boolean,
randomLoading: Boolean,
@@ -170,14 +189,15 @@ class ExploreViewModel @Inject constructor(
}
private fun List<Manga>.toRecommendationList() = map { manga ->
MangaListModel(
MangaCompactListModel(
id = manga.id,
title = manga.title,
subtitle = manga.tags.joinToString { it.title },
coverUrl = manga.coverUrl,
manga = manga,
counter = 0,
progress = PROGRESS_NONE,
progress = null,
isFavorite = false,
)
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.explore.ui.adapter
import android.view.View
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
@@ -9,11 +10,13 @@ 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.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.image.AnimatedFaviconDrawable
import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.drawableStart
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.recyclerView
@@ -31,7 +34,7 @@ import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
import org.koitharu.kotatsu.explore.ui.model.RecommendationsItem
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel
import org.koitharu.kotatsu.parsers.model.Manga
fun exploreButtonsAD(
@@ -63,7 +66,7 @@ fun exploreRecommendationItemAD(
{ layoutInflater, parent -> ItemRecommendationBinding.inflate(layoutInflater, parent, false) },
) {
val adapter = BaseListAdapter<MangaListModel>()
val adapter = BaseListAdapter<MangaCompactListModel>()
.addDelegate(ListItemType.MANGA_LIST, recommendationMangaItemAD(coil, itemClickListener, lifecycleOwner))
binding.pager.adapter = adapter
binding.pager.recyclerView?.isNestedScrollingEnabled = false
@@ -78,7 +81,7 @@ fun recommendationMangaItemAD(
coil: ImageLoader,
itemClickListener: OnListItemClickListener<Manga>,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<MangaListModel, MangaListModel, ItemRecommendationMangaBinding>(
) = adapterDelegateViewBinding<MangaCompactListModel, MangaCompactListModel, ItemRecommendationMangaBinding>(
{ layoutInflater, parent -> ItemRecommendationMangaBinding.inflate(layoutInflater, parent, false) },
) {
@@ -115,6 +118,7 @@ fun exploreSourceListItemAD(
) {
val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
val iconPinned = ContextCompat.getDrawable(context, R.drawable.ic_pin_small)
binding.root.setOnClickListener(eventListener)
binding.root.setOnLongClickListener(eventListener)
@@ -122,11 +126,12 @@ fun exploreSourceListItemAD(
bind {
binding.textViewTitle.text = item.source.getTitle(context)
binding.textViewTitle.drawableStart = if (item.source.isPinned) iconPinned else null
binding.textViewSubtitle.text = item.source.getSummary(context)
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
fallback(fallbackIcon)
placeholder(fallbackIcon)
placeholder(AnimatedFaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name))
error(fallbackIcon)
source(item.source)
enqueueWith(coil)
@@ -150,6 +155,7 @@ fun exploreSourceGridItemAD(
) {
val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
val iconPinned = ContextCompat.getDrawable(context, R.drawable.ic_pin_small)
binding.root.setOnClickListener(eventListener)
binding.root.setOnLongClickListener(eventListener)
@@ -157,10 +163,11 @@ fun exploreSourceGridItemAD(
bind {
binding.textViewTitle.text = item.source.getTitle(context)
binding.textViewTitle.drawableStart = if (item.source.isPinned) iconPinned else null
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Large, item.source.name)
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
fallback(fallbackIcon)
placeholder(fallbackIcon)
placeholder(AnimatedFaviconDrawable(context, R.style.FaviconDrawable_Large, item.source.name))
error(fallbackIcon)
source(item.source)
enqueueWith(coil)

View File

@@ -1,11 +1,11 @@
package org.koitharu.kotatsu.explore.ui.model
import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaSource
data class MangaSourceItem(
val source: MangaSource,
val source: MangaSourceInfo,
val isGrid: Boolean,
) : ListModel {

View File

@@ -1,10 +1,10 @@
package org.koitharu.kotatsu.explore.ui.model
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel
data class RecommendationsItem(
val manga: List<MangaListModel>
val manga: List<MangaCompactListModel>
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {

View File

@@ -115,6 +115,9 @@ abstract class FavouritesDao {
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0 ORDER BY favourites.created_at ASC")
abstract suspend fun findCategoriesIds(mangaIds: Collection<Long>): List<Long>
@Query("SELECT COUNT(category_id) FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0")
abstract suspend fun findCategoriesCount(mangaId: Long): Int
/** INSERT **/
@Insert(onConflict = OnConflictStrategy.REPLACE)

View File

@@ -115,6 +115,10 @@ class FavouritesRepository @Inject constructor(
return db.getFavouriteCategoriesDao().find(id.toInt()).toFavouriteCategory()
}
suspend fun isFavorite(mangaId: Long): Boolean {
return db.getFavouritesDao().findCategoriesCount(mangaId) != 0
}
suspend fun getCategoriesIds(mangaIds: Collection<Long>): Set<Long> {
return db.getFavouritesDao().findCategoriesIds(mangaIds).toSet()
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.favourites.ui.categories
import androidx.collection.LongSet
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
@@ -76,7 +77,7 @@ class FavouritesCategoriesViewModel @Inject constructor(
}
}
fun getCategories(ids: Set<Long>): ArrayList<FavouriteCategory> {
fun getCategories(ids: LongSet): ArrayList<FavouriteCategory> {
val items = content.requireValue()
return items.mapNotNullTo(ArrayList(ids.size)) { item ->
(item as? CategoryListModel)?.category?.takeIf { it.id in ids }

View File

@@ -24,13 +24,12 @@ import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
import org.koitharu.kotatsu.history.domain.MarkAsReadUseCase
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
@@ -41,7 +40,7 @@ private const val PAGE_SIZE = 20
class FavouritesListViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: FavouritesRepository,
private val listExtraProvider: ListExtraProvider,
private val mangaListMapper: MangaListMapper,
private val markAsReadUseCase: MarkAsReadUseCase,
settings: AppSettings,
downloadScheduler: DownloadWorker.Scheduler,
@@ -67,7 +66,7 @@ class FavouritesListViewModel @Inject constructor(
override val content = combine(
observeFavorites(),
listMode,
observeListModeWithTriggers(),
refreshTrigger,
) { list, mode, _ ->
when {
@@ -86,7 +85,7 @@ class FavouritesListViewModel @Inject constructor(
else -> {
isReady.set(true)
list.toUi(mode, listExtraProvider)
mangaListMapper.toListModelList(list, mode)
}
}
}.catch {

View File

@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
@@ -31,7 +32,6 @@ import org.koitharu.kotatsu.core.util.LocaleComparator
import org.koitharu.kotatsu.core.util.ext.asArrayList
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
@@ -67,7 +67,7 @@ class FilterCoordinator @Inject constructor(
) : MangaFilter {
private val coroutineScope = lifecycle.lifecycleScope
private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE))
private val repository = mangaRepositoryFactory.create(MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE]))
private val currentState = MutableStateFlow(
MangaListFilter.Advanced(
sortOrder = repository.defaultSortOrder,

View File

@@ -88,7 +88,7 @@ abstract class HistoryDao {
abstract suspend fun insert(entity: HistoryEntity): Long
@Query(
"UPDATE history SET page = :page, chapter_id = :chapterId, scroll = :scroll, percent = :percent, updated_at = :updatedAt, deleted_at = 0 WHERE manga_id = :mangaId",
"UPDATE history SET page = :page, chapter_id = :chapterId, scroll = :scroll, percent = :percent, updated_at = :updatedAt, chapters = :chapters, deleted_at = 0 WHERE manga_id = :mangaId",
)
abstract suspend fun update(
mangaId: Long,
@@ -96,6 +96,7 @@ abstract class HistoryDao {
chapterId: Long,
scroll: Float,
percent: Float,
chapters: Int,
updatedAt: Long,
): Int
@@ -116,6 +117,7 @@ abstract class HistoryDao {
chapterId = entity.chapterId,
scroll = entity.scroll,
percent = entity.percent,
chapters = entity.chaptersCount,
updatedAt = entity.updatedAt,
)

View File

@@ -19,10 +19,12 @@ import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.mapItems
import org.koitharu.kotatsu.history.domain.model.MangaWithHistory
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
@@ -102,6 +104,7 @@ class HistoryRepository @Inject constructor(
assert(manga.chapters != null)
db.withTransaction {
mangaRepository.storeManga(manga)
val branch = manga.chapters?.findById(chapterId)?.branch
db.getHistoryDao().upsert(
HistoryEntity(
mangaId = manga.id,
@@ -111,7 +114,7 @@ class HistoryRepository @Inject constructor(
page = page,
scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
percent = percent,
chaptersCount = manga.chapters?.size ?: -1,
chaptersCount = manga.chapters?.count { it.branch == branch } ?: 0,
deletedAt = 0L,
),
)
@@ -124,8 +127,13 @@ class HistoryRepository @Inject constructor(
return db.getHistoryDao().find(manga.id)?.recoverIfNeeded(manga)?.toMangaHistory()
}
suspend fun getProgress(mangaId: Long): Float {
return db.getHistoryDao().findProgress(mangaId) ?: PROGRESS_NONE
suspend fun getProgress(mangaId: Long, mode: ProgressIndicatorMode): ReadingProgress? {
val entity = db.getHistoryDao().find(mangaId) ?: return null
return ReadingProgress(
percent = entity.percent,
totalChapters = entity.chaptersCount,
mode = mode,
).takeIf { it.isValid() }
}
suspend fun clear() {

View File

@@ -28,8 +28,8 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.domain.MarkAsReadUseCase
import org.koitharu.kotatsu.history.domain.model.MangaWithHistory
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyHint
import org.koitharu.kotatsu.list.ui.model.EmptyState
@@ -38,9 +38,6 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.TipModel
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toGridModel
import org.koitharu.kotatsu.list.ui.model.toListDetailedModel
import org.koitharu.kotatsu.list.ui.model.toListModel
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import java.time.Instant
@@ -53,7 +50,7 @@ private const val PAGE_SIZE = 20
class HistoryListViewModel @Inject constructor(
private val repository: HistoryRepository,
settings: AppSettings,
private val extraProvider: ListExtraProvider,
private val mangaListMapper: MangaListMapper,
private val localMangaRepository: LocalMangaRepository,
private val markAsReadUseCase: MarkAsReadUseCase,
networkState: NetworkState,
@@ -91,7 +88,7 @@ class HistoryListViewModel @Inject constructor(
override val content = combine(
observeHistory(),
isGroupingEnabled,
listMode,
observeListModeWithTriggers(),
networkState,
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled },
) { list, grouped, mode, online, incognito ->
@@ -203,11 +200,7 @@ class HistoryListViewModel @Inject constructor(
prevHeader = header
}
}
result += when (mode) {
ListMode.LIST -> manga.toListModel(extraProvider)
ListMode.DETAILED_LIST -> manga.toListDetailedModel(extraProvider)
ListMode.GRID -> manga.toGridModel(extraProvider)
}
result += mangaListMapper.toListModel(manga, mode)
}
return result
}

View File

@@ -26,7 +26,6 @@ class ReadingProgressDrawable(
private val outlineColor: Int
private val backgroundColor: Int
private val textColor: Int
private val textPattern = context.getString(R.string.percent_string_pattern)
private val textBounds = Rect()
private val tempRect = Rect()
private val hasBackground: Boolean
@@ -36,14 +35,18 @@ class ReadingProgressDrawable(
private val desiredWidth: Int
private val autoFitTextSize: Boolean
var progress: Float = PROGRESS_NONE
var percent: Float = PROGRESS_NONE
set(value) {
field = value
invalidateSelf()
}
var text = ""
set(value) {
field = value
text = textPattern.format((value * 100f).toInt().toString())
paint.getTextBounds(text, 0, text.length, textBounds)
invalidateSelf()
}
private var text = ""
init {
val ta = context.obtainStyledAttributes(styleResId, R.styleable.ProgressDrawable)
@@ -79,7 +82,7 @@ class ReadingProgressDrawable(
}
override fun draw(canvas: Canvas) {
if (progress < 0f) {
if (percent < 0f) {
return
}
val cx = bounds.exactCenterX()
@@ -103,12 +106,12 @@ class ReadingProgressDrawable(
cx + innerRadius,
cy + innerRadius,
-90f,
360f * progress,
360f * percent,
false,
paint,
)
if (hasText) {
if (checkDrawable != null && progress >= 1f - Math.ulp(progress)) {
if (checkDrawable != null && percent >= 1f - Math.ulp(percent)) {
tempRect.set(bounds)
tempRect.scale(0.6)
checkDrawable.bounds = tempRect

View File

@@ -11,8 +11,14 @@ import android.view.animation.AccelerateDecelerateInterpolator
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.CHAPTERS_LEFT
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.CHAPTERS_READ
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.NONE
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.PERCENT_LEFT
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.PERCENT_READ
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ReadingProgress
class ReadingProgressView @JvmOverloads constructor(
context: Context,
@@ -20,17 +26,30 @@ class ReadingProgressView @JvmOverloads constructor(
@AttrRes defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener, Animator.AnimatorListener {
private val percentPattern = context.getString(R.string.percent_string_pattern)
private var percentAnimator: ValueAnimator? = null
private val animationDuration = context.getAnimationDuration(android.R.integer.config_shortAnimTime)
@StyleRes
private val drawableStyle: Int
var percent: Float
get() = peekProgressDrawable()?.progress ?: PROGRESS_NONE
var progress: ReadingProgress? = null
set(value) {
field = value
cancelAnimation()
getProgressDrawable().progress = value
getProgressDrawable().also {
it.percent = value?.percent ?: PROGRESS_NONE
it.text = when (value?.mode) {
null,
NONE -> ""
PERCENT_READ -> percentPattern.format((value.percent * 100f).toInt().toString())
PERCENT_LEFT -> "-" + percentPattern.format((value.percentLeft * 100f).toInt().toString())
CHAPTERS_READ -> value.chapters.toString()
CHAPTERS_LEFT -> "-" + value.chaptersLeft.toString()
}
}
}
init {
@@ -39,7 +58,11 @@ class ReadingProgressView @JvmOverloads constructor(
ta.recycle()
outlineProvider = OutlineProvider()
if (isInEditMode) {
percent = 0.27f
progress = ReadingProgress(
percent = 0.27f,
totalChapters = 20,
mode = PERCENT_READ,
)
}
}
@@ -53,7 +76,7 @@ class ReadingProgressView @JvmOverloads constructor(
override fun onAnimationUpdate(animation: ValueAnimator) {
val p = animation.animatedValue as Float
getProgressDrawable().progress = p
getProgressDrawable().percent = p
}
override fun onAnimationStart(animation: Animator) = Unit
@@ -68,16 +91,25 @@ class ReadingProgressView @JvmOverloads constructor(
override fun onAnimationRepeat(animation: Animator) = Unit
fun setPercent(value: Float, animate: Boolean) {
fun setProgress(percent: Float, animate: Boolean) {
setProgress(
value = ReadingProgress(percent, 1, PERCENT_READ),
animate = animate,
)
}
fun setProgress(value: ReadingProgress?, animate: Boolean) {
val currentDrawable = peekProgressDrawable()
if (!animate || currentDrawable == null || value == PROGRESS_NONE) {
percent = value
if (!animate || currentDrawable == null || value == null) {
progress = value
return
}
percentAnimator?.cancel()
val currentPercent = currentDrawable.percent.coerceAtLeast(0f)
progress = value.copy(percent = currentPercent)
percentAnimator = ValueAnimator.ofFloat(
currentDrawable.progress.coerceAtLeast(0f),
value,
currentDrawable.percent.coerceAtLeast(0f),
value.percent,
).apply {
duration = animationDuration
interpolator = AccelerateDecelerateInterpolator()

View File

@@ -1,60 +0,0 @@
package org.koitharu.kotatsu.list.domain
import android.content.Context
import androidx.annotation.ColorRes
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import javax.inject.Inject
@Reusable
class ListExtraProvider @Inject constructor(
@ApplicationContext context: Context,
private val settings: AppSettings,
private val trackingRepository: TrackingRepository,
private val historyRepository: HistoryRepository,
) {
private val dict by lazy {
context.resources.openRawResource(R.raw.tags_redlist).use {
val set = HashSet<String>()
it.bufferedReader().forEachLine { x ->
val line = x.trim()
if (line.isNotEmpty()) {
set.add(line)
}
}
set
}
}
suspend fun getCounter(mangaId: Long): Int {
return if (settings.isTrackerEnabled) {
trackingRepository.getNewChaptersCount(mangaId)
} else {
0
}
}
suspend fun getProgress(mangaId: Long): Float {
return if (settings.isReadingIndicatorsEnabled) {
historyRepository.getProgress(mangaId)
} else {
PROGRESS_NONE
}
}
@ColorRes
fun getTagTint(tag: MangaTag): Int {
return if (tag.title.lowercase() in dict) {
R.color.warning
} else {
0
}
}
}

View File

@@ -0,0 +1,131 @@
package org.koitharu.kotatsu.list.domain
import android.content.Context
import androidx.annotation.ColorRes
import androidx.collection.MutableScatterSet
import androidx.collection.ScatterSet
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel
import org.koitharu.kotatsu.list.ui.model.MangaDetailedListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MangaListMapper @Inject constructor(
@ApplicationContext context: Context,
private val settings: AppSettings,
private val trackingRepository: TrackingRepository,
private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository,
) {
private val dict by lazy { readTagsDict(context) }
suspend fun toListModelList(manga: Collection<Manga>, mode: ListMode): List<MangaListModel> = manga.map {
toListModel(it, mode)
}
suspend fun toListModelList(
destination: MutableCollection<in MangaListModel>,
manga: Collection<Manga>,
mode: ListMode
) = manga.mapTo(destination) {
toListModel(it, mode)
}
suspend fun toListModel(manga: Manga, mode: ListMode): MangaListModel = when (mode) {
ListMode.LIST -> toCompactListModel(manga)
ListMode.DETAILED_LIST -> toDetailedListModel(manga)
ListMode.GRID -> toGridModel(manga)
}
suspend fun toCompactListModel(manga: Manga) = MangaCompactListModel(
id = manga.id,
title = manga.title,
subtitle = manga.tags.joinToString(", ") { it.title },
coverUrl = manga.coverUrl,
manga = manga,
counter = getCounter(manga.id),
progress = getProgress(manga.id),
isFavorite = isFavorite(manga.id),
)
suspend fun toDetailedListModel(manga: Manga) = MangaDetailedListModel(
id = manga.id,
title = manga.title,
subtitle = manga.altTitle,
coverUrl = manga.coverUrl,
manga = manga,
counter = getCounter(manga.id),
progress = getProgress(manga.id),
isFavorite = isFavorite(manga.id),
tags = mapTags(manga.tags),
)
suspend fun toGridModel(manga: Manga) = MangaGridModel(
id = manga.id,
title = manga.title,
coverUrl = manga.coverUrl,
manga = manga,
counter = getCounter(manga.id),
progress = getProgress(manga.id),
isFavorite = isFavorite(manga.id),
)
fun mapTags(tags: Collection<MangaTag>) = tags.map {
ChipsView.ChipModel(
tint = getTagTint(it),
title = it.title,
data = it,
)
}
private suspend fun getCounter(mangaId: Long): Int {
return if (settings.isTrackerEnabled) {
trackingRepository.getNewChaptersCount(mangaId)
} else {
0
}
}
private suspend fun getProgress(mangaId: Long): ReadingProgress? {
return historyRepository.getProgress(mangaId, settings.progressIndicatorMode)
}
private fun isFavorite(mangaId: Long): Boolean {
return false // TODO favouritesRepository.isFavorite(mangaId)
}
@ColorRes
private fun getTagTint(tag: MangaTag): Int {
return if (tag.title.lowercase() in dict) {
R.color.warning
} else {
0
}
}
private fun readTagsDict(context: Context): ScatterSet<String> =
context.resources.openRawResource(R.raw.tags_redlist).use {
val set = MutableScatterSet<String>()
it.bufferedReader().forEachLine { x ->
val line = x.trim()
if (line.isNotEmpty()) {
set.add(line)
}
}
set.trim()
set
}
}

View File

@@ -0,0 +1,37 @@
package org.koitharu.kotatsu.list.domain
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.CHAPTERS_LEFT
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.CHAPTERS_READ
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.NONE
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.PERCENT_LEFT
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.PERCENT_READ
data class ReadingProgress(
val percent: Float,
val totalChapters: Int,
val mode: ProgressIndicatorMode,
) {
val percentLeft: Float
get() = 1f - percent
val chapters: Int
get() = (totalChapters * percent).toInt()
val chaptersLeft: Int
get() = (totalChapters * percentLeft).toInt()
fun isValid() = when (mode) {
NONE -> false
PERCENT_READ,
PERCENT_LEFT -> percent in 0f..1f
CHAPTERS_READ,
CHAPTERS_LEFT -> totalChapters > 0 && percent in 0f..1f
}
fun isComplete() = percent >= 1f - Math.ulp(percent)
fun isReversed() = mode == PERCENT_LEFT || mode == CHAPTERS_LEFT
}

View File

@@ -20,7 +20,6 @@ import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -51,7 +50,7 @@ import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
@@ -281,7 +280,7 @@ abstract class MangaListFragment :
return when (item.itemId) {
R.id.action_select_all -> {
val ids = listAdapter?.items?.mapNotNull {
(it as? MangaItemModel)?.id
(it as? MangaListModel)?.id
} ?: return false
selectionController?.addAll(ids)
true
@@ -327,7 +326,7 @@ abstract class MangaListFragment :
val items = listAdapter?.items ?: return emptySet()
val result = ArraySet<Manga>(checkedIds.size)
for (item in items) {
if (item is MangaItemModel && item.id in checkedIds) {
if (item is MangaListModel && item.id in checkedIds) {
result.add(item.manga)
}
}

View File

@@ -2,11 +2,16 @@ package org.koitharu.kotatsu.list.ui
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
@@ -55,4 +60,13 @@ abstract class MangaListViewModel(
} else {
this
}
protected fun observeListModeWithTriggers(): Flow<ListMode> = combine(
listMode,
settings.observe().filter { key ->
key == AppSettings.KEY_PROGRESS_INDICATORS || key == AppSettings.KEY_TRACKER_ENABLED
}.onStart { emit("") }
) { mode, _ ->
mode
}
}

View File

@@ -14,7 +14,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.core.util.ext.getItem
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import com.google.android.material.R as materialR
open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
@@ -37,7 +37,7 @@ open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDec
override fun getItemId(parent: RecyclerView, child: View): Long {
val holder = parent.getChildViewHolder(child) ?: return NO_ID
val item = holder.getItem(MangaItemModel::class.java) ?: return NO_ID
val item = holder.getItem(MangaListModel::class.java) ?: return NO_ID
return item.id
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.list.ui.adapter
import android.view.View
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.google.android.material.badge.BadgeDrawable
@@ -14,7 +15,7 @@ import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemMangaGridBinding
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver
@@ -41,7 +42,8 @@ fun mangaGridItemAD(
bind { payloads ->
binding.textViewTitle.text = item.title
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
binding.progressView.setProgress(item.progress, PAYLOAD_PROGRESS_CHANGED in payloads)
binding.imageViewFavorite.isVisible = item.isFavorite
binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run {
size(CoverSizeResolver(binding.imageViewCover))
defaultPlaceholders(context)

View File

@@ -16,13 +16,13 @@ import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel
import org.koitharu.kotatsu.list.ui.model.MangaDetailedListModel
fun mangaListDetailedItemAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: MangaDetailsClickListener,
) = adapterDelegateViewBinding<MangaListDetailedModel, ListModel, ItemMangaListDetailsBinding>(
) = adapterDelegateViewBinding<MangaDetailedListModel, ListModel, ItemMangaListDetailsBinding>(
{ inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) },
) {
var badge: BadgeDrawable? = null
@@ -39,7 +39,10 @@ fun mangaListDetailedItemAD(
bind { payloads ->
binding.textViewTitle.text = item.title
binding.textViewAuthor.textAndVisible = item.manga.author
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
binding.progressView.setProgress(
value = item.progress,
animate = ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads,
)
binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run {
size(CoverSizeResolver(binding.imageViewCover))
defaultPlaceholders(context)

View File

@@ -15,14 +15,14 @@ import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemMangaListBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel
import org.koitharu.kotatsu.parsers.model.Manga
fun mangaListItemAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga>,
) = adapterDelegateViewBinding<MangaListModel, ListModel, ItemMangaListBinding>(
) = adapterDelegateViewBinding<MangaCompactListModel, ListModel, ItemMangaListBinding>(
{ inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) },
) {
var badge: BadgeDrawable? = null

View File

@@ -3,74 +3,8 @@ package org.koitharu.kotatsu.list.ui.model
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.getDisplayIcon
import org.koitharu.kotatsu.core.util.ext.ifZero
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.parsers.model.Manga
suspend fun Manga.toListModel(
extraProvider: ListExtraProvider?
) = MangaListModel(
id = id,
title = title,
subtitle = tags.joinToString(", ") { it.title },
coverUrl = coverUrl,
manga = this,
counter = extraProvider?.getCounter(id) ?: 0,
progress = extraProvider?.getProgress(id) ?: PROGRESS_NONE,
)
suspend fun Manga.toListDetailedModel(
extraProvider: ListExtraProvider?,
) = MangaListDetailedModel(
id = id,
title = title,
subtitle = altTitle,
coverUrl = coverUrl,
manga = this,
counter = extraProvider?.getCounter(id) ?: 0,
progress = extraProvider?.getProgress(id) ?: PROGRESS_NONE,
tags = tags.map {
ChipsView.ChipModel(
tint = extraProvider?.getTagTint(it) ?: 0,
title = it.title,
data = it,
)
},
)
suspend fun Manga.toGridModel(
extraProvider: ListExtraProvider?,
) = MangaGridModel(
id = id,
title = title,
coverUrl = coverUrl,
manga = this,
counter = extraProvider?.getCounter(id) ?: 0,
progress = extraProvider?.getProgress(id) ?: PROGRESS_NONE,
)
suspend fun List<Manga>.toUi(
mode: ListMode,
extraProvider: ListExtraProvider,
): List<MangaItemModel> = if (isEmpty()) {
emptyList()
} else {
toUi(ArrayList(size), mode, extraProvider)
}
suspend fun <C : MutableCollection<in MangaItemModel>> List<Manga>.toUi(
destination: C,
mode: ListMode,
extraProvider: ListExtraProvider,
): C = when (mode) {
ListMode.LIST -> mapTo(destination) { it.toListModel(extraProvider) }
ListMode.DETAILED_LIST -> mapTo(destination) { it.toListDetailedModel(extraProvider) }
ListMode.GRID -> mapTo(destination) { it.toGridModel(extraProvider) }
}
fun Throwable.toErrorState(canRetry: Boolean = true, @StringRes secondaryAction: Int = 0) = ErrorState(
exception = this,

View File

@@ -0,0 +1,15 @@
package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.parsers.model.Manga
data class MangaCompactListModel(
override val id: Long,
override val title: String,
val subtitle: String,
override val coverUrl: String,
override val manga: Manga,
override val counter: Int,
override val progress: ReadingProgress?,
override val isFavorite: Boolean,
) : MangaListModel()

View File

@@ -1,15 +1,17 @@
package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.parsers.model.Manga
data class MangaListDetailedModel(
data class MangaDetailedListModel(
override val id: Long,
override val title: String,
val subtitle: String?,
override val coverUrl: String,
override val manga: Manga,
override val counter: Int,
override val progress: Float,
override val progress: ReadingProgress?,
override val isFavorite: Boolean,
val tags: List<ChipsView.ChipModel>,
) : MangaItemModel()
) : MangaListModel()

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.parsers.model.Manga
data class MangaGridModel(
@@ -8,5 +9,6 @@ data class MangaGridModel(
override val coverUrl: String,
override val manga: Manga,
override val counter: Int,
override val progress: Float,
) : MangaItemModel()
override val progress: ReadingProgress?,
override val isFavorite: Boolean,
) : MangaListModel()

View File

@@ -1,31 +0,0 @@
package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
sealed class MangaItemModel : ListModel {
abstract val id: Long
abstract val manga: Manga
abstract val title: String
abstract val coverUrl: String
abstract val counter: Int
abstract val progress: Float
val source: MangaSource
get() = manga.source
override fun areItemsTheSame(other: ListModel): Boolean {
return other is MangaItemModel && other.javaClass == javaClass && id == other.id
}
override fun getChangePayload(previousState: ListModel): Any? {
return when {
previousState !is MangaItemModel -> super.getChangePayload(previousState)
progress != previousState.progress -> ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED
counter != previousState.counter -> ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED
else -> null
}
}
}

View File

@@ -1,13 +1,34 @@
package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
data class MangaListModel(
override val id: Long,
override val title: String,
val subtitle: String,
override val coverUrl: String,
override val manga: Manga,
override val counter: Int,
override val progress: Float,
) : MangaItemModel()
sealed class MangaListModel : ListModel {
abstract val id: Long
abstract val manga: Manga
abstract val title: String
abstract val coverUrl: String
abstract val counter: Int
abstract val isFavorite: Boolean
abstract val progress: ReadingProgress?
val source: MangaSource
get() = manga.source
override fun areItemsTheSame(other: ListModel): Boolean {
return other is MangaListModel && other.javaClass == javaClass && id == other.id
}
override fun getChangePayload(previousState: ListModel): Any? = when {
previousState !is MangaListModel || previousState.manga != manga -> null
previousState.progress != progress -> PAYLOAD_PROGRESS_CHANGED
previousState.isFavorite != isFavorite || previousState.counter != counter -> PAYLOAD_ANYTHING_CHANGED
else -> null
}
}

View File

@@ -25,18 +25,17 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.domain.MangaListMapper
import javax.inject.Inject
@HiltViewModel
class PreviewViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val extraProvider: ListExtraProvider,
private val mangaListMapper: MangaListMapper,
private val repositoryFactory: MangaRepository.Factory,
private val historyRepository: HistoryRepository,
private val imageGetter: Html.ImageGetter,
@@ -81,13 +80,7 @@ class PreviewViewModel @Inject constructor(
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), null)
val tagsChips = manga.map {
it.tags.map { tag ->
ChipsView.ChipModel(
title = tag.title,
tint = extraProvider.getTagTint(tag),
data = tag,
)
}
mangaListMapper.mapTags(it.tags)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
init {

View File

@@ -82,6 +82,13 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
}
}
suspend fun clear() {
val cache = lruCache.get()
runInterruptible(Dispatchers.IO) {
cache.clearCache()
}
}
private suspend fun getAvailableSize(): Long = runCatchingCancellable {
val statFs = StatFs(cacheDir.get().absolutePath)
statFs.availableBytes

View File

@@ -13,12 +13,13 @@ import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.list.ui.model.TipModel
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.LocalStorageManager
@@ -34,19 +35,21 @@ class LocalListViewModel @Inject constructor(
filter: FilterCoordinator,
private val settings: AppSettings,
downloadScheduler: DownloadWorker.Scheduler,
listExtraProvider: ListExtraProvider,
mangaListMapper: MangaListMapper,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
exploreRepository: ExploreRepository,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
private val localStorageManager: LocalStorageManager,
sourcesRepository: MangaSourcesRepository,
) : RemoteListViewModel(
savedStateHandle,
mangaRepositoryFactory,
filter,
settings,
listExtraProvider,
mangaListMapper,
downloadScheduler,
exploreRepository,
sourcesRepository,
), SharedPreferences.OnSharedPreferenceChangeListener {
val onMangaRemoved = MutableEventFlow<Unit>()
@@ -67,7 +70,7 @@ class LocalListViewModel @Inject constructor(
return
}
for (item in list) {
if (item !is MangaItemModel) {
if (item !is MangaListModel) {
continue
}
val file = item.manga.url.toUriOrNull()?.toFileOrNull() ?: continue

View File

@@ -12,7 +12,7 @@ import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.exception.ParseException
@@ -73,7 +73,7 @@ class CoverRestoreInterceptor @Inject constructor(
if (dataRepository.findMangaById(manga.id) == null) {
return false
}
val repo = repositoryFactory.create(manga.source) as? RemoteMangaRepository ?: return false
val repo = repositoryFactory.create(manga.source) as? ParserMangaRepository ?: return false
val fixed = repo.find(manga) ?: return false
return if (fixed != manga) {
dataRepository.storeManga(fixed)
@@ -100,7 +100,7 @@ class CoverRestoreInterceptor @Inject constructor(
}
private suspend fun restoreBookmarkImpl(bookmark: Bookmark): Boolean {
val repo = repositoryFactory.create(bookmark.manga.source) as? RemoteMangaRepository ?: return false
val repo = repositoryFactory.create(bookmark.manga.source) as? ParserMangaRepository ?: return false
val chapter = repo.getDetails(bookmark.manga).chapters?.findById(bookmark.chapterId) ?: return false
val page = repo.getPages(chapter)[bookmark.page]
val imageUrl = page.preview.ifNullOrEmpty { page.url }

View File

@@ -17,6 +17,7 @@ import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricPrompt.AuthenticationCallback
import androidx.core.graphics.Insets
import com.google.android.material.textfield.TextInputLayout
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity
@@ -25,6 +26,7 @@ import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivityProtectBinding
import com.google.android.material.R as materialR
@AndroidEntryPoint
class ProtectActivity :
@@ -34,6 +36,7 @@ class ProtectActivity :
View.OnClickListener {
private val viewModel by viewModels<ProtectViewModel>()
private var canUseBiometric = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -61,7 +64,9 @@ class ProtectActivity :
override fun onStart() {
super.onStart()
if (!useFingerprint()) {
canUseBiometric = useFingerprint()
updateEndIcon()
if (!canUseBiometric) {
viewBinding.editPassword.requestFocus()
}
}
@@ -80,6 +85,7 @@ class ProtectActivity :
when (v.id) {
R.id.button_next -> viewModel.tryUnlock(viewBinding.editPassword.text?.toString().orEmpty())
R.id.button_cancel -> finish()
materialR.id.text_input_end_icon -> useFingerprint()
}
}
@@ -99,6 +105,7 @@ class ProtectActivity :
override fun afterTextChanged(s: Editable?) {
viewBinding.layoutPassword.error = null
viewBinding.buttonNext.isEnabled = !s.isNullOrEmpty()
updateEndIcon()
}
private fun onError(e: Throwable) {
@@ -127,6 +134,24 @@ class ProtectActivity :
return true
}
private fun updateEndIcon() = with(viewBinding.layoutPassword) {
val isFingerprintIcon = canUseBiometric && viewBinding.editPassword.text.isNullOrEmpty()
if (isFingerprintIcon == (endIconMode == TextInputLayout.END_ICON_CUSTOM)) {
return@with
}
if (isFingerprintIcon) {
endIconMode = TextInputLayout.END_ICON_CUSTOM
setEndIconDrawable(androidx.biometric.R.drawable.fingerprint_dialog_fp_icon)
endIconContentDescription = getString(androidx.biometric.R.string.use_biometric_label)
setEndIconOnClickListener(this@ProtectActivity)
} else {
setEndIconOnClickListener(null)
setEndIconDrawable(0)
endIconContentDescription = null
endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
}
}
private inner class BiometricCallback : AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.domain
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Size
import androidx.core.net.toFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient
@@ -14,6 +15,8 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.local.data.isFileUri
import org.koitharu.kotatsu.local.data.isZipUri
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -61,19 +64,28 @@ class DetectReaderModeUseCase @Inject constructor(
val page = requireNotNull(pages.getOrNull(pageIndex)) { "No pages" }
val url = repository.getPageUrl(page)
val uri = Uri.parse(url)
val size = if (uri.scheme == "cbz") {
runInterruptible(Dispatchers.IO) {
val size = when {
uri.isZipUri() -> runInterruptible(Dispatchers.IO) {
val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment)
zip.getInputStream(entry).use {
getBitmapSize(it)
}
}
} else {
val request = PageLoader.createPageRequest(page, url)
imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use {
runInterruptible(Dispatchers.IO) {
getBitmapSize(it.body?.byteStream())
uri.isFileUri() -> runInterruptible(Dispatchers.IO) {
uri.toFile().inputStream().use {
getBitmapSize(it)
}
}
else -> {
val request = PageLoader.createPageRequest(url, page.source)
imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use {
runInterruptible(Dispatchers.IO) {
getBitmapSize(it.body?.byteStream())
}
}
}
}

View File

@@ -0,0 +1,150 @@
package org.koitharu.kotatsu.reader.domain
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.Point
import android.graphics.Rect
import androidx.annotation.ColorInt
import androidx.core.graphics.alpha
import androidx.core.graphics.blue
import androidx.core.graphics.get
import androidx.core.graphics.green
import androidx.core.graphics.red
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.decoder.ImageRegionDecoder
import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.util.ext.use
import kotlin.math.abs
class EdgeDetector(private val context: Context) {
private val mutex = Mutex()
suspend fun getBounds(imageSource: ImageSource): Rect? = mutex.withLock {
withContext(Dispatchers.IO) {
val decoder = SkiaPooledImageRegionDecoder(Bitmap.Config.RGB_565)
try {
val size = runInterruptible {
decoder.init(context, imageSource)
}
val edges = coroutineScope {
listOf(
async { detectLeftRightEdge(decoder, size, isLeft = true) },
async { detectTopBottomEdge(decoder, size, isTop = true) },
async { detectLeftRightEdge(decoder, size, isLeft = false) },
async { detectTopBottomEdge(decoder, size, isTop = false) },
).awaitAll()
}
var hasEdges = false
for (edge in edges) {
if (edge > 0) {
hasEdges = true
} else if (edge < 0) {
return@withContext null
}
}
if (hasEdges) {
Rect(edges[0], edges[1], size.x - edges[2], size.y - edges[3])
} else {
null
}
} finally {
decoder.recycle()
}
}
}
private fun detectLeftRightEdge(decoder: ImageRegionDecoder, size: Point, isLeft: Boolean): Int {
var width = size.x
val rectCount = size.x / BLOCK_SIZE
val maxRect = rectCount / 3
for (i in 0 until rectCount) {
if (i > maxRect) {
return -1
}
var dd = BLOCK_SIZE
for (j in 0 until size.y / BLOCK_SIZE) {
val regionX = if (isLeft) i * BLOCK_SIZE else size.x - (i + 1) * BLOCK_SIZE
decoder.decodeRegion(region(regionX, j * BLOCK_SIZE), 1).use { bitmap ->
for (ii in 0 until minOf(BLOCK_SIZE, dd)) {
for (jj in 0 until BLOCK_SIZE) {
val bi = if (isLeft) ii else BLOCK_SIZE - ii - 1
if (bitmap[bi, jj].isNotWhite()) {
width = minOf(width, BLOCK_SIZE * i + ii)
dd--
break
}
}
}
}
if (dd == 0) {
break
}
}
if (dd < BLOCK_SIZE) {
break // We have already found vertical field or it is not exist
}
}
return width
}
private fun detectTopBottomEdge(decoder: ImageRegionDecoder, size: Point, isTop: Boolean): Int {
var height = size.y
val rectCount = size.y / BLOCK_SIZE
val maxRect = rectCount / 3
for (j in 0 until rectCount) {
if (j > maxRect) {
return -1
}
var dd = BLOCK_SIZE
for (i in 0 until size.x / BLOCK_SIZE) {
val regionY = if (isTop) j * BLOCK_SIZE else size.y - (j + 1) * BLOCK_SIZE
decoder.decodeRegion(region(i * BLOCK_SIZE, regionY), 1).use { bitmap ->
for (jj in 0 until minOf(BLOCK_SIZE, dd)) {
for (ii in 0 until BLOCK_SIZE) {
val bj = if (isTop) jj else BLOCK_SIZE - jj - 1
if (bitmap[ii, bj].isNotWhite()) {
height = minOf(height, BLOCK_SIZE * j + jj)
dd--
break
}
}
}
}
if (dd == 0) {
break
}
}
if (dd < BLOCK_SIZE) {
break // We have already found vertical field or it is not exist
}
}
return height
}
companion object {
private const val BLOCK_SIZE = 100
private const val COLOR_TOLERANCE = 16
fun isColorTheSame(@ColorInt a: Int, @ColorInt b: Int, tolerance: Int): Boolean {
return abs(a.red - b.red) <= tolerance &&
abs(a.green - b.green) <= tolerance &&
abs(a.blue - b.blue) <= tolerance &&
abs(a.alpha - b.alpha) <= tolerance
}
private fun Int.isNotWhite() = !isColorTheSame(this, Color.WHITE, COLOR_TOLERANCE)
private fun region(x: Int, y: Int) = Rect(x, y, x + BLOCK_SIZE, y + BLOCK_SIZE)
}
}

View File

@@ -2,12 +2,14 @@ package org.koitharu.kotatsu.reader.domain
import android.content.Context
import android.graphics.BitmapFactory
import android.graphics.Rect
import android.net.Uri
import androidx.annotation.AnyThread
import androidx.collection.LongSparseArray
import androidx.collection.set
import androidx.core.net.toFile
import androidx.core.net.toUri
import com.davemorrissey.labs.subscaleview.ImageSource
import dagger.hilt.android.ActivityRetainedLifecycle
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ActivityRetainedScoped
@@ -30,11 +32,12 @@ import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP
import org.koitharu.kotatsu.core.util.ext.cancelChildrenAndJoin
import org.koitharu.kotatsu.core.util.ext.compressToPNG
import org.koitharu.kotatsu.core.util.ext.ensureRamAtLeast
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
@@ -44,6 +47,7 @@ import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode
import org.koitharu.kotatsu.core.util.ext.isTargetNotEmpty
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.ramAvailable
import org.koitharu.kotatsu.core.util.ext.use
import org.koitharu.kotatsu.core.util.ext.withProgress
import org.koitharu.kotatsu.core.util.progress.ProgressDeferred
import org.koitharu.kotatsu.local.data.PagesCache
@@ -51,6 +55,7 @@ import org.koitharu.kotatsu.local.data.isFileUri
import org.koitharu.kotatsu.local.data.isZipUri
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import java.util.LinkedList
import java.util.concurrent.atomic.AtomicInteger
@@ -83,9 +88,10 @@ class PageLoader @Inject constructor(
private val prefetchQueue = LinkedList<MangaPage>()
private val counter = AtomicInteger(0)
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
private val edgeDetector = EdgeDetector(context)
fun isPrefetchApplicable(): Boolean {
return repository is RemoteMangaRepository
return repository is ParserMangaRepository
&& settings.isPagesPreloadEnabled
&& !context.isPowerSaveMode()
&& !isLowRam()
@@ -142,22 +148,33 @@ class PageLoader @Inject constructor(
} else {
val file = uri.toFile()
context.ensureRamAtLeast(file.length() * 2)
val image = runInterruptible(Dispatchers.IO) {
runInterruptible(Dispatchers.IO) {
BitmapFactory.decodeFile(file.absolutePath)
}
try {
}.use { image ->
image.compressToPNG(file)
} finally {
image.recycle()
}
uri
}
}
suspend fun getTrimmedBounds(uri: Uri): Rect? = runCatchingCancellable {
edgeDetector.getBounds(ImageSource.Uri(uri))
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrNull()
suspend fun getPageUrl(page: MangaPage): String {
return getRepository(page.source).getPageUrl(page)
}
suspend fun invalidate(clearCache: Boolean) {
tasks.clear()
loaderScope.cancelChildrenAndJoin()
if (clearCache) {
cache.clear()
}
}
private fun onIdle() = loaderScope.launch {
prefetchLock.withLock {
while (prefetchQueue.isNotEmpty()) {
@@ -213,7 +230,7 @@ class PageLoader @Inject constructor(
uri.isFileUri() -> uri
else -> {
val request = createPageRequest(page, pageUrl)
val request = createPageRequest(pageUrl, page.source)
imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response ->
val body = checkNotNull(response.body) { "Null response body" }
body.withProgress(progress).use {
@@ -248,12 +265,12 @@ class PageLoader @Inject constructor(
private const val PREFETCH_LIMIT_DEFAULT = 6
private const val PREFETCH_MIN_RAM_MB = 80L
fun createPageRequest(page: MangaPage, pageUrl: String) = Request.Builder()
fun createPageRequest(pageUrl: String, mangaSource: MangaSource) = Request.Builder()
.url(pageUrl)
.get()
.header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8")
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
.tag(MangaSource::class.java, page.source)
.tag(MangaSource::class.java, mangaSource)
.build()
}
}

View File

@@ -73,7 +73,7 @@ private const val PREFETCH_LIMIT = 10
class ReaderViewModel
@Inject
constructor(
savedStateHandle: SavedStateHandle,
private val savedStateHandle: SavedStateHandle,
private val dataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository,
private val bookmarksRepository: BookmarksRepository,
@@ -223,6 +223,7 @@ constructor(
fun saveCurrentState(state: ReaderState? = null) {
if (state != null) {
currentState.value = state
savedStateHandle[ReaderActivity.EXTRA_STATE] = state
}
if (incognitoMode.value) {
return
@@ -283,7 +284,9 @@ constructor(
prevJob?.cancelAndJoin()
content.value = ReaderContent(emptyList(), null)
chaptersLoader.loadSingleChapter(id)
content.value = ReaderContent(chaptersLoader.snapshot(), ReaderState(id, page, 0))
val newState = ReaderState(id, page, 0)
content.value = ReaderContent(chaptersLoader.snapshot(), newState)
saveCurrentState(newState)
}
}
@@ -291,17 +294,27 @@ constructor(
val prevJob = loadingJob
loadingJob = launchLoadingJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
val currentChapterId = currentState.requireValue().chapterId
val allChapters = checkNotNull(manga).allChapters
var index = allChapters.indexOfFirst { x -> x.id == currentChapterId }
if (index < 0) {
return@launchLoadingJob
val prevState = currentState.requireValue()
val newChapterId = if (delta != 0) {
val allChapters = checkNotNull(manga).allChapters
var index = allChapters.indexOfFirst { x -> x.id == prevState.chapterId }
if (index < 0) {
return@launchLoadingJob
}
index += delta
(allChapters.getOrNull(index) ?: return@launchLoadingJob).id
} else {
prevState.chapterId
}
index += delta
val newChapterId = (allChapters.getOrNull(index) ?: return@launchLoadingJob).id
content.value = ReaderContent(emptyList(), null)
chaptersLoader.loadSingleChapter(newChapterId)
content.value = ReaderContent(chaptersLoader.snapshot(), ReaderState(newChapterId, 0, 0))
val newState = ReaderState(
chapterId = newChapterId,
page = if (delta == 0) prevState.page else 0,
scroll = if (delta == 0) prevState.scroll else 0,
)
content.value = ReaderContent(chaptersLoader.snapshot(), newState)
saveCurrentState(newState)
}
}

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