Compare commits

..

122 Commits
v7.2 ... v7.4.2

Author SHA1 Message Date
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
Koitharu
0d8820bcab Dynamic sources support 2024-06-29 07:56:18 +03:00
Koitharu
77bb5c2fcd Support UNKNOWN manga source 2024-06-22 13:19:21 +03:00
Milo Ivir
475a4904a9 Translated using Weblate (Croatian)
Currently translated at 100.0% (648 of 648 strings)

Translated using Weblate (Croatian)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/hr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-06-22 12:56:50 +03:00
lukapiplica
cf43b8ebda Translated using Weblate (Croatian)
Currently translated at 100.0% (648 of 648 strings)

Co-authored-by: lukapiplica <github163007297@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
2024-06-22 12:56:50 +03:00
Duh051
f34096af98 Translated using Weblate (Arabic)
Currently translated at 60.1% (390 of 648 strings)

Co-authored-by: Duh051 <duhduh272@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-06-22 12:56:50 +03:00
Bela K
d60ff2a052 Translated using Weblate (German)
Currently translated at 95.2% (617 of 648 strings)

Co-authored-by: Bela K <na0341-dev@posteo.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2024-06-22 12:56:50 +03:00
Anon
59d4953554 Translated using Weblate (Serbian)
Currently translated at 100.0% (648 of 648 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-06-22 12:56:50 +03:00
acetknan
f76052b1d6 Translated using Weblate (Arabic)
Currently translated at 55.4% (359 of 648 strings)

Translated using Weblate (Arabic)

Currently translated at 88.8% (8 of 9 strings)

Co-authored-by: acetknan <acetknan18@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-06-22 12:56:50 +03:00
Koitharu
26e59b0953 Fix opening reader with specific branch 2024-06-22 12:55:53 +03:00
Koitharu
9ee1164f08 Update parsers 2024-06-22 12:13:52 +03:00
Koitharu
cfc3823593 Improve double-tap zoom in reader 2024-06-22 10:20:48 +03:00
Koitharu
8407a414c5 Fix crashes 2024-06-15 14:03:38 +03:00
Koitharu
a379604974 Transfer scrobbling information within migration #930 2024-06-15 13:49:57 +03:00
Koitharu
c01d80f7da Update dependencies 2024-06-15 10:51:42 +03:00
lukapiplica
7533dce0d2 Translated using Weblate (Croatian)
Currently translated at 100.0% (648 of 648 strings)

Translated using Weblate (Croatian)

Currently translated at 100.0% (9 of 9 strings)

Added translation using Weblate (Croatian)

Added translation using Weblate (Croatian)

Co-authored-by: lukapiplica <github163007297@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/hr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-06-15 10:44:09 +03:00
Fikri Akbar
9f1e97fd54 Translated using Weblate (Indonesian)
Currently translated at 99.0% (642 of 648 strings)

Co-authored-by: Fikri Akbar <akbarfikri1221@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-06-15 10:44:09 +03:00
Johan
382a73310c Translated using Weblate (Czech)
Currently translated at 100.0% (648 of 648 strings)

Translated using Weblate (Czech)

Currently translated at 98.7% (640 of 648 strings)

Co-authored-by: Johan <jakub.bilku@outlook.cz>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translation: Kotatsu/Strings
2024-06-15 10:44:09 +03:00
Infy's Tagalog Translations
5eeab7fd08 Translated using Weblate (Filipino)
Currently translated at 100.0% (648 of 648 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-06-15 10:44:09 +03:00
gekka
bc54e7cfba Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (648 of 648 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-06-15 10:44:09 +03:00
248 changed files with 4715 additions and 1275 deletions

1
.gitignore vendored
View File

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

1
.idea/.gitignore generated vendored
View File

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

View File

@@ -15,9 +15,9 @@ android {
defaultConfig { defaultConfig {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 35
versionCode = 648 versionCode = 659
versionName = '7.2' versionName = '7.4.2'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {
@@ -82,23 +82,23 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:56fd22b43f') { implementation('com.github.KotatsuApp:kotatsu-parsers:ca212ca692') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24' implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.10-RC'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0-RC'
implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.core:core-ktx:1.13.1' implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.activity:activity-ktx:1.9.0' implementation 'androidx.activity:activity-ktx:1.9.1'
implementation 'androidx.fragment:fragment-ktx:1.7.1' implementation 'androidx.fragment:fragment-ktx:1.8.2'
implementation 'androidx.transition:transition-ktx:1.5.0' implementation 'androidx.transition:transition-ktx:1.5.1'
implementation 'androidx.collection:collection-ktx:1.4.0' implementation 'androidx.collection:collection-ktx:1.4.3'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4'
implementation 'androidx.lifecycle:lifecycle-service:2.8.1' implementation 'androidx.lifecycle:lifecycle-service:2.8.4'
implementation 'androidx.lifecycle:lifecycle-process:2.8.1' implementation 'androidx.lifecycle:lifecycle-process:2.8.4'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'androidx.recyclerview:recyclerview:1.3.2'
@@ -106,10 +106,10 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.12.0' implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.1' implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.4'
implementation 'androidx.webkit:webkit:1.11.0' 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 //noinspection GradleDependency
implementation('com.google.guava:guava:32.0.1-android') { implementation('com.google.guava:guava:32.0.1-android') {
exclude group: 'com.google.guava', module: 'failureaccess' exclude group: 'com.google.guava', module: 'failureaccess'
@@ -134,9 +134,9 @@ dependencies {
implementation 'androidx.hilt:hilt-work:1.2.0' implementation 'androidx.hilt:hilt-work:1.2.0'
kapt 'androidx.hilt:hilt-compiler: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-base:2.7.0'
implementation 'io.coil-kt:coil-svg:2.6.0' implementation 'io.coil-kt:coil-svg:2.7.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:882bc0620c'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2' implementation 'io.noties.markwon:core:4.6.2'
@@ -154,10 +154,10 @@ dependencies {
testImplementation 'org.json:json:20240303' testImplementation 'org.json:json:20240303'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:runner:1.6.1'
androidTestImplementation 'androidx.test:rules:1.5.0' androidTestImplementation 'androidx.test:rules:1.6.1'
androidTestImplementation 'androidx.test:core-ktx:1.5.0' androidTestImplementation 'androidx.test:core-ktx:1.6.1'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5' androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.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.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <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_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 <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" /> android:maxSdkVersion="29" />

View File

@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.core.util.ext.almostEquals
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
@@ -57,7 +58,7 @@ class AlternativesUseCase @Inject constructor(
} }
private suspend fun getSources(ref: MangaSource): List<MangaSource> { private suspend fun getSources(ref: MangaSource): List<MangaSource> {
val result = ArrayList<MangaSource>(MangaSource.entries.size - 2) val result = ArrayList<MangaSource>(MangaParserSource.entries.size - 2)
result.addAll(sourcesRepository.getEnabledSources()) result.addAll(sourcesRepository.getEnabledSources())
result.sortByDescending { it.priority(ref) } result.sortByDescending { it.priority(ref) }
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) }) result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
@@ -78,8 +79,10 @@ class AlternativesUseCase @Inject constructor(
private fun MangaSource.priority(ref: MangaSource): Int { private fun MangaSource.priority(ref: MangaSource): Int {
var res = 0 var res = 0
if (locale == ref.locale) res += 2 if (this is MangaParserSource && ref is MangaParserSource) {
if (contentType == ref.contentType) res++ if (locale == ref.locale) res += 2
if (contentType == ref.contentType) res++
}
return res return res
} }
} }

View File

@@ -12,29 +12,38 @@ import org.koitharu.kotatsu.history.data.toMangaHistory
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.tracker.data.TrackEntity import org.koitharu.kotatsu.tracker.data.TrackEntity
import javax.inject.Inject import javax.inject.Inject
class MigrateUseCase @Inject constructor( class MigrateUseCase
@Inject
constructor(
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val mangaDataRepository: MangaDataRepository, private val mangaDataRepository: MangaDataRepository,
private val database: MangaDatabase, private val database: MangaDatabase,
private val progressUpdateUseCase: ProgressUpdateUseCase, private val progressUpdateUseCase: ProgressUpdateUseCase,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
) { ) {
suspend operator fun invoke(
suspend operator fun invoke(oldManga: Manga, newManga: Manga) { oldManga: Manga,
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) { newManga: Manga,
runCatchingCancellable { ) {
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga) val oldDetails =
}.getOrDefault(oldManga) if (oldManga.chapters.isNullOrEmpty()) {
} else { runCatchingCancellable {
oldManga mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
} }.getOrDefault(oldManga)
val newDetails = if (newManga.chapters.isNullOrEmpty()) { } else {
mangaRepositoryFactory.create(newManga.source).getDetails(newManga) oldManga
} else { }
newManga val newDetails =
} if (newManga.chapters.isNullOrEmpty()) {
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
} else {
newManga
}
mangaDataRepository.storeManga(newDetails) mangaDataRepository.storeManga(newDetails)
database.withTransaction { database.withTransaction {
// replace favorites // replace favorites
@@ -43,37 +52,69 @@ class MigrateUseCase @Inject constructor(
if (oldFavourites.isNotEmpty()) { if (oldFavourites.isNotEmpty()) {
favoritesDao.delete(oldManga.id) favoritesDao.delete(oldManga.id)
for (f in oldFavourites) { for (f in oldFavourites) {
val e = f.copy( val e =
mangaId = newManga.id, f.copy(
) mangaId = newManga.id,
)
favoritesDao.upsert(e) favoritesDao.upsert(e)
} }
} }
// replace history // replace history
val historyDao = database.getHistoryDao() val historyDao = database.getHistoryDao()
val oldHistory = historyDao.find(oldDetails.id) val oldHistory = historyDao.find(oldDetails.id)
if (oldHistory != null) { val newHistory =
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory) if (oldHistory != null) {
historyDao.delete(oldDetails.id) val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
historyDao.upsert(newHistory) historyDao.delete(oldDetails.id)
} historyDao.upsert(newHistory)
newHistory
} else {
null
}
// track // track
val tracksDao = database.getTracksDao() val tracksDao = database.getTracksDao()
val oldTrack = tracksDao.find(oldDetails.id) val oldTrack = tracksDao.find(oldDetails.id)
if (oldTrack != null) { if (oldTrack != null) {
val lastChapter = newDetails.chapters?.lastOrNull() val lastChapter = newDetails.chapters?.lastOrNull()
val newTrack = TrackEntity( val newTrack =
mangaId = newDetails.id, TrackEntity(
lastChapterId = lastChapter?.id ?: 0L, mangaId = newDetails.id,
newChapters = 0, lastChapterId = lastChapter?.id ?: 0L,
lastCheckTime = System.currentTimeMillis(), newChapters = 0,
lastChapterDate = lastChapter?.uploadDate ?: 0L, lastCheckTime = System.currentTimeMillis(),
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION, lastChapterDate = lastChapter?.uploadDate ?: 0L,
lastError = null, lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
) lastError = null,
)
tracksDao.delete(oldDetails.id) tracksDao.delete(oldDetails.id)
tracksDao.upsert(newTrack) tracksDao.upsert(newTrack)
} }
// 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,
)
if (newHistory != null) {
scrobbler.scrobble(
manga = newDetails,
chapterId = newHistory.chapterId,
)
}
}
} }
progressUpdateUseCase(newManga) progressUpdateUseCase(newManga)
} }
@@ -86,11 +127,12 @@ class MigrateUseCase @Inject constructor(
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
val branch = newManga.getPreferredBranch(null) val branch = newManga.getPreferredBranch(null)
val chapters = checkNotNull(newManga.getChapters(branch)) val chapters = checkNotNull(newManga.getChapters(branch))
val currentChapter = if (history.percent in 0f..1f) { val currentChapter =
chapters[(chapters.lastIndex * history.percent).toInt()] if (history.percent in 0f..1f) {
} else { chapters[(chapters.lastIndex * history.percent).toInt()]
chapters.first() } else {
} chapters.first()
}
return HistoryEntity( return HistoryEntity(
mangaId = newManga.id, mangaId = newManga.id,
createdAt = history.createdAt, createdAt = history.createdAt,
@@ -100,29 +142,33 @@ class MigrateUseCase @Inject constructor(
scroll = history.scroll, scroll = history.scroll,
percent = history.percent, percent = history.percent,
deletedAt = 0, deletedAt = 0,
chaptersCount = chapters.size, chaptersCount = chapters.count { it.branch == currentChapter.branch },
) )
} }
val branch = oldManga.getPreferredBranch(history.toMangaHistory()) val branch = oldManga.getPreferredBranch(history.toMangaHistory())
val oldChapters = checkNotNull(oldManga.getChapters(branch)) val oldChapters = checkNotNull(oldManga.getChapters(branch))
var index = oldChapters.indexOfFirst { it.id == history.chapterId } var index = oldChapters.indexOfFirst { it.id == history.chapterId }
if (index < 0) { if (index < 0) {
index = if (history.percent in 0f..1f) { index =
(oldChapters.lastIndex * history.percent).toInt() if (history.percent in 0f..1f) {
} else { (oldChapters.lastIndex * history.percent).toInt()
0 } else {
} 0
}
} }
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch } val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
val newBranch = if (newChapters.containsKey(branch)) { val newBranch =
branch if (newChapters.containsKey(branch)) {
} else { branch
newManga.getPreferredBranch(null) } else {
} newManga.getPreferredBranch(null)
val newChapterId = checkNotNull(newChapters[newBranch]).let { }
val oldChapter = oldChapters[index] val newChapterId =
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last() checkNotNull(newChapters[newBranch])
}.id .let {
val oldChapter = oldChapters[index]
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
}.id
return HistoryEntity( return HistoryEntity(
mangaId = newManga.id, mangaId = newManga.id,
@@ -137,11 +183,13 @@ class MigrateUseCase @Inject constructor(
) )
} }
private fun List<MangaChapter>.findByNumber(volume: Int, number: Float): MangaChapter? { private fun List<MangaChapter>.findByNumber(
return if (number <= 0f) { volume: Int,
number: Float,
): MangaChapter? =
if (number <= 0f) {
null null
} else { } else {
firstOrNull { it.volume == volume && it.number == number } firstOrNull { it.volume == volume && it.number == number }
} }
}
} }

View File

@@ -10,6 +10,7 @@ import coil.request.ImageRequest
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
@@ -61,9 +62,9 @@ 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 -> binding.chipSource.also { chip ->
chip.text = item.manga.source.title chip.text = item.manga.source.getTitle(chip.context)
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(item.manga.source.faviconUri()) .data(item.manga.source.faviconUri())
.lifecycle(lifecycleOwner) .lifecycle(lifecycleOwner)

View File

@@ -13,6 +13,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
@@ -95,9 +96,9 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
getString( getString(
R.string.migrate_confirmation, R.string.migrate_confirmation,
viewModel.manga.title, viewModel.manga.title,
viewModel.manga.source.title, viewModel.manga.source.getTitle(this),
target.title, target.title,
target.source.title, target.source.getTitle(this),
), ),
) )
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)

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

View File

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

View File

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

View File

@@ -12,13 +12,14 @@ import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import okhttp3.internal.userAgent
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository 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.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -42,10 +43,9 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
setDisplayHomeAsUpEnabled(true) setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
} }
val userAgent = intent?.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)?.let { source -> val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
val repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
repository?.headers?.get(CommonHeaders.USER_AGENT) repository?.headers?.get(CommonHeaders.USER_AGENT)
}
viewBinding.webView.configureForParser(userAgent) viewBinding.webView.configureForParser(userAgent)
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true) CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
viewBinding.webView.webViewClient = BrowserClient(this) viewBinding.webView.webViewClient = BrowserClient(this)
@@ -147,7 +147,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
return Intent(context, BrowserActivity::class.java) return Intent(context, BrowserActivity::class.java)
.setData(Uri.parse(url)) .setData(Uri.parse(url))
.putExtra(EXTRA_TITLE, title) .putExtra(EXTRA_TITLE, title)
.putExtra(EXTRA_SOURCE, source) .putExtra(EXTRA_SOURCE, source?.name)
} }
} }
} }

View File

@@ -14,8 +14,9 @@ import coil.request.ErrorResult
import coil.request.ImageRequest import coil.request.ImageRequest
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
class CaptchaNotifier( class CaptchaNotifier(
@@ -46,7 +47,7 @@ class CaptchaNotifier(
.setGroup(GROUP_CAPTCHA) .setGroup(GROUP_CAPTCHA)
.setAutoCancel(true) .setAutoCancel(true)
.setVisibility( .setVisibility(
if (exception.source?.contentType == ContentType.HENTAI) { if (exception.source?.isNsfw() == true) {
NotificationCompat.VISIBILITY_SECRET NotificationCompat.VISIBILITY_SECRET
} else { } else {
NotificationCompat.VISIBILITY_PUBLIC NotificationCompat.VISIBILITY_PUBLIC
@@ -55,7 +56,7 @@ class CaptchaNotifier(
.setContentText( .setContentText(
context.getString( context.getString(
R.string.captcha_required_summary, R.string.captcha_required_summary,
exception.source?.title ?: context.getString(R.string.app_name), exception.source?.getTitle(context) ?: context.getString(R.string.app_name),
), ),
) )
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false)) .setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))

View File

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

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core package org.koitharu.kotatsu.core
import android.app.Application import android.app.Application
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.provider.SearchRecentSuggestions import android.provider.SearchRecentSuggestions
import android.text.Html import android.text.Html
@@ -110,6 +111,8 @@ interface AppModule {
.decoderDispatcher(Dispatchers.IO) .decoderDispatcher(Dispatchers.IO)
.transformationDispatcher(Dispatchers.Default) .transformationDispatcher(Dispatchers.Default)
.diskCache(diskCacheFactory) .diskCache(diskCacheFactory)
.respectCacheHeaders(false)
.networkObserverEnabled(false)
.logger(if (BuildConfig.DEBUG) DebugLogger() else null) .logger(if (BuildConfig.DEBUG) DebugLogger() else null)
.allowRgb565(context.isLowRamDevice()) .allowRgb565(context.isLowRamDevice())
.eventListener(CaptchaNotifier(context)) .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.getBooleanOrDefault
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
class JsonDeserializer(private val json: JSONObject) { class JsonDeserializer(private val json: JSONObject) {
@@ -85,6 +86,8 @@ class JsonDeserializer(private val json: JSONObject) {
isEnabled = json.getBoolean("enabled"), isEnabled = json.getBoolean("enabled"),
sortKey = json.getInt("sort_key"), sortKey = json.getInt("sort_key"),
addedIn = json.getIntOrDefault("added_in", 0), addedIn = json.getIntOrDefault("added_in", 0),
lastUsedAt = json.getLongOrDefault("used_at", 0L),
isPinned = json.getBooleanOrDefault("pinned", false),
) )
fun toMap(): Map<String, Any?> { fun toMap(): Map<String, Any?> {

View File

@@ -89,6 +89,9 @@ class JsonSerializer private constructor(private val json: JSONObject) {
put("source", e.source) put("source", e.source)
put("enabled", e.isEnabled) put("enabled", e.isEnabled)
put("sort_key", e.sortKey) 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.Migration19To20
import org.koitharu.kotatsu.core.db.migrations.Migration1To2 import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration20To21 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.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4 import org.koitharu.kotatsu.core.db.migrations.Migration3To4
import org.koitharu.kotatsu.core.db.migrations.Migration4To5 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.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 21 const val DATABASE_VERSION = 22
@Database( @Database(
entities = [ entities = [
@@ -120,6 +121,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration18To19(), Migration18To19(),
Migration19To20(), Migration19To20(),
Migration20To21(), Migration20To21(),
Migration21To22(),
) )
fun MangaDatabase(context: Context): MangaDatabase = Room fun MangaDatabase(context: Context): MangaDatabase = Room

View File

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

View File

@@ -4,7 +4,7 @@ import android.content.Context
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
class Migration16To17(context: Context) : Migration(16, 17) { class Migration16To17(context: Context) : Migration(16, 17) {
@@ -15,11 +15,8 @@ class Migration16To17(context: Context) : Migration(16, 17) {
db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)") db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty() val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty() val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
val sources = MangaSource.entries val sources = MangaParserSource.entries
for (source in sources) { for (source in sources) {
if (source == MangaSource.LOCAL) {
continue
}
val name = source.name val name = source.name
val isHidden = name in hiddenSources val isHidden = name in hiddenSources
var sortKey = order.indexOf(name) var sortKey = order.indexOf(name)

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

@@ -11,7 +11,6 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.formatSimple import org.koitharu.kotatsu.parsers.util.formatSimple
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
@@ -109,7 +108,7 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
} }
val Manga.isLocal: Boolean val Manga.isLocal: Boolean
get() = source == MangaSource.LOCAL get() = source == LocalMangaSource
val Manga.appUrl: Uri val Manga.appUrl: Uri
get() = Uri.parse("https://kotatsu.app/manga").buildUpon() get() = Uri.parse("https://kotatsu.app/manga").buildUpon()

View File

@@ -7,24 +7,47 @@ import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan import android.text.style.RelativeSizeSpan
import android.text.style.SuperscriptSpan import android.text.style.SuperscriptSpan
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans import androidx.core.text.inSpans
import org.koitharu.kotatsu.R 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.getDisplayName
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.core.util.ext.toLocale
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.splitTwoParts
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
fun MangaSource(name: String): MangaSource { data object LocalMangaSource : MangaSource {
MangaSource.entries.forEach { override val name = "LOCAL"
if (it.name == name) return it
}
return MangaSource.DUMMY
} }
fun MangaSource.isNsfw() = contentType == ContentType.HENTAI data object UnknownMangaSource : MangaSource {
override val name = "UNKNOWN"
}
fun MangaSource(name: String?): MangaSource {
when (name ?: return UnknownMangaSource) {
UnknownMangaSource.name -> return UnknownMangaSource
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
}
return UnknownMangaSource
}
fun MangaSource.isNsfw(): Boolean = when (this) {
is MangaSourceInfo -> mangaSource.isNsfw()
is MangaParserSource -> contentType == ContentType.HENTAI
else -> false
}
@get:StringRes @get:StringRes
val ContentType.titleResId val ContentType.titleResId
@@ -35,23 +58,28 @@ val ContentType.titleResId
ContentType.OTHER -> R.string.content_type_other ContentType.OTHER -> R.string.content_type_other
} }
fun MangaSource.getSummary(context: Context): String { fun MangaSource.getSummary(context: Context): String? = when (this) {
val type = context.getString(contentType.titleResId) is MangaSourceInfo -> mangaSource.getSummary(context)
val locale = locale.toLocale().getDisplayName(context) is MangaParserSource -> {
return context.getString(R.string.source_summary_pattern, type, locale) val type = context.getString(contentType.titleResId)
} val locale = locale.toLocale().getDisplayName(context)
context.getString(R.string.source_summary_pattern, type, locale)
fun MangaSource.getTitle(context: Context): CharSequence = if (isNsfw()) {
buildSpannedString {
append(title)
append(' ')
appendNsfwLabel(context)
} }
} else {
title is ExternalMangaSource -> context.getString(R.string.external_source)
else -> null
} }
private fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans( 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)
}
fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)), ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)),
RelativeSizeSpan(0.74f), RelativeSizeSpan(0.74f),
SuperscriptSpan(), SuperscriptSpan(),

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

@@ -0,0 +1,15 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import kotlinx.parcelize.Parceler
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaSource
class MangaSourceParceler : Parceler<MangaSource> {
override fun create(parcel: Parcel): MangaSource = MangaSource(parcel.readString())
override fun MangaSource.write(parcel: Parcel, flags: Int) {
parcel.writeString(name)
}
}

View File

@@ -4,9 +4,8 @@ import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parceler import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
@Parcelize @Parcelize
data class ParcelableChapter( data class ParcelableChapter(
@@ -25,8 +24,8 @@ data class ParcelableChapter(
scanlator = parcel.readString(), scanlator = parcel.readString(),
uploadDate = parcel.readLong(), uploadDate = parcel.readLong(),
branch = parcel.readString(), branch = parcel.readString(),
source = parcel.readSerializableCompat() ?: MangaSource.DUMMY, source = MangaSource(parcel.readString()),
) ),
) )
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) { override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
@@ -38,7 +37,7 @@ data class ParcelableChapter(
parcel.writeString(scanlator) parcel.writeString(scanlator)
parcel.writeLong(uploadDate) parcel.writeLong(uploadDate)
parcel.writeString(branch) parcel.writeString(branch)
parcel.writeSerializable(source) parcel.writeString(source.name)
} }
} }
} }

View File

@@ -5,6 +5,7 @@ import android.os.Parcelable
import androidx.core.os.ParcelCompat import androidx.core.os.ParcelCompat
import kotlinx.parcelize.Parceler import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -30,7 +31,7 @@ data class ParcelableManga(
parcel.writeParcelable(ParcelableMangaTags(tags), flags) parcel.writeParcelable(ParcelableMangaTags(tags), flags)
parcel.writeSerializable(state) parcel.writeSerializable(state)
parcel.writeString(author) parcel.writeString(author)
parcel.writeSerializable(source) parcel.writeString(source.name)
} }
override fun create(parcel: Parcel) = ParcelableManga( override fun create(parcel: Parcel) = ParcelableManga(
@@ -49,8 +50,8 @@ data class ParcelableManga(
state = parcel.readSerializableCompat(), state = parcel.readSerializableCompat(),
author = parcel.readString(), author = parcel.readString(),
chapters = null, chapters = null,
source = requireNotNull(parcel.readSerializableCompat()), source = MangaSource(parcel.readString()),
) ),
) )
} }
} }

View File

@@ -5,7 +5,7 @@ import android.os.Parcelable
import kotlinx.parcelize.Parceler import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
object MangaPageParceler : Parceler<MangaPage> { object MangaPageParceler : Parceler<MangaPage> {
@@ -13,14 +13,14 @@ object MangaPageParceler : Parceler<MangaPage> {
id = parcel.readLong(), id = parcel.readLong(),
url = requireNotNull(parcel.readString()), url = requireNotNull(parcel.readString()),
preview = parcel.readString(), preview = parcel.readString(),
source = requireNotNull(parcel.readSerializableCompat()), source = MangaSource(parcel.readString()),
) )
override fun MangaPage.write(parcel: Parcel, flags: Int) { override fun MangaPage.write(parcel: Parcel, flags: Int) {
parcel.writeLong(id) parcel.writeLong(id)
parcel.writeString(url) parcel.writeString(url)
parcel.writeString(preview) parcel.writeString(preview)
parcel.writeSerializable(source) parcel.writeString(source.name)
} }
} }

View File

@@ -5,20 +5,20 @@ import android.os.Parcelable
import kotlinx.parcelize.Parceler import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
object MangaTagParceler : Parceler<MangaTag> { object MangaTagParceler : Parceler<MangaTag> {
override fun create(parcel: Parcel) = MangaTag( override fun create(parcel: Parcel) = MangaTag(
title = requireNotNull(parcel.readString()), title = requireNotNull(parcel.readString()),
key = requireNotNull(parcel.readString()), key = requireNotNull(parcel.readString()),
source = requireNotNull(parcel.readSerializableCompat()), source = MangaSource(parcel.readString()),
) )
override fun MangaTag.write(parcel: Parcel, flags: Int) { override fun MangaTag.write(parcel: Parcel, flags: Int) {
parcel.writeString(title) parcel.writeString(title)
parcel.writeString(key) parcel.writeString(key)
parcel.writeSerializable(source) parcel.writeString(source.name)
} }
} }

View File

@@ -11,7 +11,7 @@ import okio.IOException
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.core.parser.MangaRepository 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.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mergeWith import org.koitharu.kotatsu.parsers.util.mergeWith
@@ -30,7 +30,7 @@ class CommonHeadersInterceptor @Inject constructor(
val request = chain.request() val request = chain.request()
val source = request.tag(MangaSource::class.java) val source = request.tag(MangaSource::class.java)
val repository = if (source != null) { val repository = if (source != null) {
mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
} else { } else {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.w("Http", "Request without source tag: ${request.url}") Log.w("Http", "Request without source tag: ${request.url}")

View File

@@ -13,8 +13,9 @@ import okhttp3.internal.canParseAsIpAddress
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import okhttp3.internal.publicsuffix.PublicSuffixDatabase import okhttp3.internal.publicsuffix.PublicSuffixDatabase
import org.koitharu.kotatsu.core.parser.MangaRepository 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.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.EnumMap import java.util.EnumMap
import javax.inject.Inject import javax.inject.Inject
@@ -26,8 +27,8 @@ class MirrorSwitchInterceptor @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
) : Interceptor { ) : Interceptor {
private val locks = EnumMap<MangaSource, Any>(MangaSource::class.java) private val locks = EnumMap<MangaParserSource, Any>(MangaParserSource::class.java)
private val blacklist = EnumMap<MangaSource, MutableSet<String>>(MangaSource::class.java) private val blacklist = EnumMap<MangaParserSource, MutableSet<String>>(MangaParserSource::class.java)
val isEnabled: Boolean val isEnabled: Boolean
get() = settings.isMirrorSwitchingAvailable get() = settings.isMirrorSwitchingAvailable
@@ -53,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) { if (!isEnabled) {
return@runInterruptible false return@runInterruptible false
} }
@@ -75,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) blacklist[repository.source]?.remove(oldMirror)
repository.domain = oldMirror repository.domain = oldMirror
} }
private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? { private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? {
val source = request.tag(MangaSource::class.java) ?: return null 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() val mirrors = repository.getAvailableMirrors()
if (mirrors.isEmpty()) { if (mirrors.isEmpty()) {
return null return null
@@ -93,7 +94,7 @@ class MirrorSwitchInterceptor @Inject constructor(
} }
private fun tryMirrors( private fun tryMirrors(
repository: RemoteMangaRepository, repository: ParserMangaRepository,
mirrors: List<String>, mirrors: List<String>,
chain: Interceptor.Chain, chain: Interceptor.Chain,
request: Request, request: Request,
@@ -145,15 +146,15 @@ class MirrorSwitchInterceptor @Inject constructor(
return source().readByteArray().toResponseBody(contentType()) return source().readByteArray().toResponseBody(contentType())
} }
private fun obtainLock(source: MangaSource): Any = locks.getOrPut(source) { private fun obtainLock(source: MangaParserSource): Any = locks.getOrPut(source) {
Any() Any()
} }
private fun isBlacklisted(source: MangaSource, domain: String): Boolean { private fun isBlacklisted(source: MangaParserSource, domain: String): Boolean {
return blacklist[source]?.contains(domain) == true return blacklist[source]?.contains(domain) == true
} }
private fun addToBlacklist(source: MangaSource, domain: String) { private fun addToBlacklist(source: MangaParserSource, domain: String) {
blacklist.getOrPut(source) { blacklist.getOrPut(source) {
ArraySet(2) ArraySet(2)
}.add(domain) }.add(domain)

View File

@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -173,9 +174,10 @@ class AppShortcutManager @Inject constructor(
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) }, onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) }, onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
) )
val title = source.getTitle(context)
ShortcutInfoCompat.Builder(context, source.name) ShortcutInfoCompat.Builder(context, source.name)
.setShortLabel(source.title) .setShortLabel(title)
.setLongLabel(source.title) .setLongLabel(title)
.setIcon(icon) .setIcon(icon)
.setLongLived(true) .setLongLived(true)
.setIntent(MangaListActivity.newIntent(context, source)) .setIntent(MangaListActivity.newIntent(context, source))

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

@@ -8,7 +8,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet import java.util.EnumSet
@@ -16,7 +16,7 @@ import java.util.EnumSet
/** /**
* This parser is just for parser development, it should not be used in releases * This parser is just for parser development, it should not be used in releases
*/ */
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) { class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParserSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("localhost") get() = ConfigKey.Domain("localhost")

View File

@@ -0,0 +1,51 @@
package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
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.MangaSource
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 EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)
override val states: Set<MangaState>
get() = emptySet()
override val contentRatings: Set<ContentRating>
get() = emptySet()
override var defaultSortOrder: SortOrder
get() = SortOrder.NEWEST
set(value) = Unit
override val isMultipleTagsSupported: Boolean
get() = false
override val isTagsExclusionSupported: Boolean
get() = false
override val isSearchSupported: Boolean
get() = false
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
override suspend fun getPageUrl(page: MangaPage): String = stub(null)
override suspend fun getTags(): Set<MangaTag> = stub(null)
override suspend fun getLocales(): Set<Locale> = stub(null)
override suspend fun getRelated(seed: Manga): List<Manga> = stub(seed)
private fun stub(manga: Manga?): Nothing {
throw UnsupportedSourceException("This manga source is not supported", manga)
}
}

View File

@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.core.util.ext.toFileOrNull
@@ -101,7 +102,7 @@ class MangaDataRepository @Inject constructor(
suspend fun cleanupLocalManga() { suspend fun cleanupLocalManga() {
val dao = db.getMangaDao() val dao = db.getMangaDao()
val broken = dao.findAllBySource(MangaSource.LOCAL.name) val broken = dao.findAllBySource(LocalMangaSource.name)
.filter { x -> x.manga.url.toUri().toFileOrNull()?.exists() == false } .filter { x -> x.manga.url.toUri().toFileOrNull()?.exists() == false }
if (broken.isNotEmpty()) { if (broken.isNotEmpty()) {
dao.delete(broken.map { it.manga }) dao.delete(broken.map { it.manga })

View File

@@ -4,10 +4,11 @@ import android.net.Uri
import coil.request.CachePolicy import coil.request.CachePolicy
import dagger.Reusable import dagger.Reusable
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -36,7 +37,7 @@ class MangaLinkResolver @Inject constructor(
require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" } require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" }
val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" } val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" }
val source = MangaSource(sourceName) val source = MangaSource(sourceName)
require(source != MangaSource.DUMMY) { "Manga source $sourceName is not supported" } require(source != UnknownMangaSource) { "Manga source $sourceName is not supported" }
val repo = repositoryFactory.create(source) val repo = repositoryFactory.create(source)
return repo.findExact( return repo.findExact(
url = uri.getQueryParameter("url"), url = uri.getQueryParameter("url"),
@@ -51,7 +52,7 @@ class MangaLinkResolver @Inject constructor(
val host = uri.host ?: return null val host = uri.host ?: return null
val repo = sourcesRepository.allMangaSources.asSequence() val repo = sourcesRepository.allMangaSources.asSequence()
.map { source -> .map { source ->
repositoryFactory.create(source) as RemoteMangaRepository repositoryFactory.create(source) as ParserMangaRepository
}.find { repo -> }.find { repo ->
host in repo.domains host in repo.domains
} ?: return null } ?: return null
@@ -85,7 +86,7 @@ class MangaLinkResolver @Inject constructor(
} }
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga { private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
return if (this is RemoteMangaRepository) { return if (this is ParserMangaRepository) {
getDetails(manga, CachePolicy.READ_ONLY) getDetails(manga, CachePolicy.READ_ONLY)
} else { } else {
getDetails(manga) getDetails(manga)
@@ -108,7 +109,7 @@ class MangaLinkResolver @Inject constructor(
url = url, url = url,
publicUrl = "", publicUrl = "",
rating = 0.0f, rating = 0.0f,
isNsfw = source.contentType == ContentType.HENTAI, isNsfw = source.isNsfw(),
coverUrl = "", coverUrl = "",
tags = emptySet(), tags = emptySet(),
state = null, state = null,

View File

@@ -2,12 +2,11 @@ package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaParser { fun MangaParser(source: MangaParserSource, loaderContext: MangaLoaderContext): MangaParser {
return if (source == MangaSource.DUMMY) { return when (source) {
DummyParser(loaderContext) MangaParserSource.DUMMY -> DummyParser(loaderContext)
} else { else -> loaderContext.newParserInstance(source)
loaderContext.newParserInstance(source)
} }
} }

View File

@@ -1,8 +1,16 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import android.content.Context
import androidx.annotation.AnyThread 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.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.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.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
@@ -10,12 +18,12 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.EnumMap
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -53,32 +61,60 @@ interface MangaRepository {
suspend fun getRelated(seed: Manga): List<Manga> 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 @Singleton
class Factory @Inject constructor( class Factory @Inject constructor(
@ApplicationContext private val context: Context,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val loaderContext: MangaLoaderContext, private val loaderContext: MangaLoaderContext,
private val contentCache: MemoryContentCache, private val contentCache: MemoryContentCache,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor, private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
) { ) {
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java) private val cache = ArrayMap<MangaSource, WeakReference<MangaRepository>>()
@AnyThread @AnyThread
fun create(source: MangaSource): MangaRepository { fun create(source: MangaSource): MangaRepository {
if (source == MangaSource.LOCAL) { when (source) {
return localMangaRepository is MangaSourceInfo -> return create(source.mangaSource)
LocalMangaSource -> return localMangaRepository
UnknownMangaSource -> return EmptyMangaRepository(source)
} }
cache[source]?.get()?.let { return it } cache[source]?.get()?.let { return it }
return synchronized(cache) { return synchronized(cache) {
cache[source]?.get()?.let { return it } cache[source]?.get()?.let { return it }
val repository = RemoteMangaRepository( val repository = createRepository(source)
parser = MangaParser(source, loaderContext), if (repository != null) {
cache = contentCache, cache[source] = WeakReference(repository)
mirrorSwitchInterceptor = mirrorSwitchInterceptor, repository
) } else {
cache[source] = WeakReference(repository) EmptyMangaRepository(source)
repository }
} }
} }
private fun createRepository(source: MangaSource): MangaRepository? = when (source) {
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,11 @@
package org.koitharu.kotatsu.core.parser 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.Headers
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.cache.MemoryContentCache 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.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.core.prefs.SourceSettings 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.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
@@ -28,7 +15,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -36,17 +23,13 @@ import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Locale import java.util.Locale
class RemoteMangaRepository( class ParserMangaRepository(
private val parser: MangaParser, private val parser: MangaParser,
private val cache: MemoryContentCache,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor, private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
) : MangaRepository, Interceptor { cache: MemoryContentCache,
) : CachingMangaRepository(cache), Interceptor {
private val detailsMutex = MultiMutex<Long>() override val source: MangaParserSource
private val relatedMangaMutex = MultiMutex<Long>()
private val pagesMutex = MultiMutex<Long>()
override val source: MangaSource
get() = parser.source get() = parser.source
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
@@ -99,18 +82,11 @@ class RemoteMangaRepository(
} }
} }
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED) override suspend fun getPagesImpl(
chapter: MangaChapter
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = pagesMutex.withLock(chapter.id) { ): List<MangaPage> = mirrorSwitchInterceptor.withMirrorSwitching {
cache.getPages(source, chapter.url)?.let { return it } parser.getPages(chapter)
val pages = asyncSafe { }
mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPages(chapter).distinctById()
}
}
cache.putPages(source, chapter.url, pages)
pages
}.await()
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching { override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPageUrl(page) parser.getPageUrl(page)
@@ -128,37 +104,10 @@ class RemoteMangaRepository(
parser.getFavicons() parser.getFavicons()
} }
override suspend fun getRelated(seed: Manga): List<Manga> = relatedMangaMutex.withLock(seed.id) { override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = parser.getRelatedManga(seed)
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()
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga = detailsMutex.withLock(manga.id) { override suspend fun getDetailsImpl(manga: Manga): Manga = mirrorSwitchInterceptor.withMirrorSwitching {
if (cachePolicy.readEnabled) { parser.getDetails(manga)
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 }
} }
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
@@ -175,40 +124,8 @@ class RemoteMangaRepository(
return getConfig().isSlowdownEnabled return getConfig().isSlowdownEnabled
} }
fun invalidateCache() {
cache.clear(source)
}
fun getConfig() = parser.config as SourceSettings 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 { private suspend fun <R> MirrorSwitchInterceptor.withMirrorSwitching(block: suspend () -> R): R {
if (!isEnabled) { if (!isEnabled) {
return block() return block()
@@ -220,14 +137,14 @@ class RemoteMangaRepository(
if (result.isValidResult()) { if (result.isValidResult()) {
return result.getOrThrow() return result.getOrThrow()
} }
return if (trySwitchMirror(this@RemoteMangaRepository)) { return if (trySwitchMirror(this@ParserMangaRepository)) {
val newResult = runCatchingCancellable { val newResult = runCatchingCancellable {
block() block()
} }
if (newResult.isValidResult()) { if (newResult.isValidResult()) {
return newResult.getOrThrow() return newResult.getOrThrow()
} else { } else {
rollback(this@RemoteMangaRepository, initialMirror) rollback(this@ParserMangaRepository, initialMirror)
return result.getOrThrow() return result.getOrThrow()
} }
} else { } 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 package org.koitharu.kotatsu.core.parser.favicon
import android.content.Context 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.net.Uri
import android.os.Build
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import coil.ImageLoader import coil.ImageLoader
import coil.decode.DataSource import coil.decode.DataSource
import coil.decode.ImageSource import coil.decode.ImageSource
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.fetch.DrawableResult
import coil.fetch.FetchResult import coil.fetch.FetchResult
import coil.fetch.Fetcher import coil.fetch.Fetcher
import coil.fetch.SourceResult import coil.fetch.SourceResult
@@ -14,7 +21,9 @@ import coil.network.HttpException
import coil.request.Options import coil.request.Options
import coil.size.Size import coil.size.Size
import coil.size.pxOrElse import coil.size.pxOrElse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@@ -24,8 +33,10 @@ import okio.Closeable
import okio.buffer import okio.buffer
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource 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.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.requireBody
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
@@ -46,14 +57,27 @@ class FaviconFetcher(
) : Fetcher { ) : Fetcher {
private val diskCacheKey private val diskCacheKey
get() = options.diskCacheKey ?: "${mangaSource.name}[${mangaSource.ordinal}]x${options.size.toCacheKey()}" get() = options.diskCacheKey ?: "${mangaSource.name}x${options.size.toCacheKey()}"
private val fileSystem private val fileSystem
get() = checkNotNull(diskCache.value).fileSystem get() = checkNotNull(diskCache.value).fileSystem
override suspend fun fetch(): FetchResult { override suspend fun fetch(): FetchResult {
getCached(options)?.let { return it } 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( val sizePx = maxOf(
options.size.width.pxOrElse { FALLBACK_SIZE }, options.size.width.pxOrElse { FALLBACK_SIZE },
options.size.height.pxOrElse { FALLBACK_SIZE }, options.size.height.pxOrElse { FALLBACK_SIZE },
@@ -100,6 +124,20 @@ class FaviconFetcher(
return response 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? { private fun getCached(options: Options): SourceResult? {
if (!options.diskCachePolicy.readEnabled) { if (!options.diskCachePolicy.readEnabled) {
return null 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( class Factory(
context: Context, context: Context,
okHttpClientLazy: Lazy<OkHttpClient>, okHttpClientLazy: Lazy<OkHttpClient>,

View File

@@ -33,7 +33,6 @@ import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import java.io.File import java.io.File
import java.net.Proxy import java.net.Proxy
import java.util.EnumSet import java.util.EnumSet
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -193,8 +192,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_FEED_HEADER, true) get() = prefs.getBoolean(KEY_FEED_HEADER, true)
set(value) = prefs.edit { putBoolean(KEY_FEED_HEADER, value) } set(value) = prefs.edit { putBoolean(KEY_FEED_HEADER, value) }
val isReadingIndicatorsEnabled: Boolean val progressIndicatorMode: ProgressIndicatorMode
get() = prefs.getBoolean(KEY_READING_INDICATORS, true) get() = prefs.getEnumValue(KEY_PROGRESS_INDICATORS, ProgressIndicatorMode.PERCENT_READ)
val isHistoryExcludeNsfw: Boolean val isHistoryExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_HISTORY_EXCLUDE_NSFW, false) get() = prefs.getBoolean(KEY_HISTORY_EXCLUDE_NSFW, false)
@@ -485,6 +484,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isAutoLocalChaptersCleanupEnabled: Boolean val isAutoLocalChaptersCleanupEnabled: Boolean
get() = prefs.getBoolean(KEY_CHAPTERS_CLEAR_AUTO, false) 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 { fun isTipEnabled(tip: String): Boolean {
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
} }
@@ -597,6 +605,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_ANIMATION = "reader_animation2" const val KEY_READER_ANIMATION = "reader_animation2"
const val KEY_READER_MODE = "reader_mode" const val KEY_READER_MODE = "reader_mode"
const val KEY_READER_MODE_DETECT = "reader_mode_detect" 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 = "app_password"
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num" const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
const val KEY_PROTECT_APP = "protect_app" 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_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
const val KEY_HISTORY_GROUPING = "history_grouping" const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_UPDATED_GROUPING = "updated_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_REVERSE_CHAPTERS = "reverse_chapters"
const val KEY_GRID_VIEW_CHAPTERS = "grid_view_chapters" const val KEY_GRID_VIEW_CHAPTERS = "grid_view_chapters"
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw" 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_LOGS_SHARE = "logs_share"
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_TRANSLATION = "about_app_translation" const val KEY_APP_TRANSLATION = "about_app_translation"
const val PROXY_TEST = "proxy_test"
// old keys are for migration only // old keys are for migration only
private const val KEY_IMAGES_PROXY_OLD = "images_proxy" 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), QUERIES_SUGGEST(R.string.suggested_queries),
MANGA(R.string.content_type_manga), MANGA(R.string.content_type_manga),
SOURCES(R.string.remote_sources), SOURCES(R.string.remote_sources),
RECENT_SOURCES(R.string.recent_sources),
AUTHORS(R.string.authors), 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.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
is ConfigKey.SplitByTranslations -> 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 } as T
} }
@@ -47,6 +48,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean) is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
is ConfigKey.UserAgent -> putString(key.key, (value as String?)?.sanitizeHeaderValue()) is ConfigKey.UserAgent -> putString(key.key, (value as String?)?.sanitizeHeaderValue())
is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean) is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean)
is ConfigKey.PreferredImageServer -> putString(key.key, value as String? ?: "")
} }
} }

View File

@@ -68,7 +68,7 @@ abstract class BaseViewModel : ViewModel() {
errorEvent.call(error) errorEvent.call(error)
} }
protected inline suspend fun <T> withLoading(block: () -> T): T = try { protected inline fun <T> withLoading(block: () -> T): T = try {
loadingCounter.increment() loadingCounter.increment()
block() block()
} finally { } finally {

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.R
import org.koitharu.kotatsu.core.util.KotatsuColors import org.koitharu.kotatsu.core.util.KotatsuColors
class FaviconDrawable( open class FaviconDrawable(
context: Context, context: Context,
@StyleRes styleResId: Int, @StyleRes styleResId: Int,
name: String, name: String,
) : Drawable() { ) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG) 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 var colorStroke = Color.LTGRAY
private val letter = name.take(1).uppercase() private val letter = name.take(1).uppercase()
private var cornerSize = 0f private var cornerSize = 0f
private var colorForeground = Color.DKGRAY
private val textBounds = Rect() private val textBounds = Rect()
private val tempRect = Rect() private val tempRect = Rect()
private val boundsF = RectF() private val boundsF = RectF()

View File

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

View File

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

View File

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

View File

@@ -23,11 +23,16 @@ class SelectableTextView @JvmOverloads constructor(
private fun fixSelectionRange() { private fun fixSelectionRange() {
if (selectionStart < 0 || selectionEnd < 0) { if (selectionStart < 0 || selectionEnd < 0) {
val spannableText = text as? Spannable ?: return val spannableText = text as? Spannable ?: return
Selection.setSelection(spannableText, text.length) Selection.setSelection(spannableText, spannableText.length)
} }
} }
override fun scrollTo(x: Int, y: Int) { override fun scrollTo(x: Int, y: Int) {
super.scrollTo(0, 0) 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.ArrayMap
import androidx.collection.ArraySet import androidx.collection.ArraySet
import androidx.collection.LongSet
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import java.util.Collections import java.util.Collections
import java.util.EnumSet 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.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import org.koitharu.kotatsu.core.util.AcraCoroutineErrorHandler import org.koitharu.kotatsu.core.util.AcraCoroutineErrorHandler
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.parsers.util.cancelAll
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@@ -90,3 +94,10 @@ fun <T> Deferred<T>.peek(): T? = if (isCompleted) {
} else { } else {
null 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`" 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 package org.koitharu.kotatsu.core.util.ext
import android.graphics.Bitmap
import android.graphics.Rect import android.graphics.Rect
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -11,3 +12,9 @@ fun Rect.scale(factor: Double) {
(height() - newHeight) / 2, (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.CloudFlareBlockedException
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException 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.NoDataReceivedException
import org.koitharu.kotatsu.core.exceptions.SyncApiException import org.koitharu.kotatsu.core.exceptions.SyncApiException
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions 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) -> resources.getString(R.string.network_error)
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received) 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 WrongPasswordException -> resources.getString(R.string.wrong_password)
is NotFoundException -> resources.getString(R.string.not_found_404) is NotFoundException -> resources.getString(R.string.not_found_404)
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source) is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)

View File

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

View File

@@ -5,7 +5,9 @@ import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.findById import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -62,7 +64,7 @@ class MangaPrefetchService : CoroutineIntentService() {
private suspend fun prefetchLast() { private suspend fun prefetchLast() {
val last = historyRepository.getLastOrNull() ?: return val last = historyRepository.getLastOrNull() ?: return
if (last.source == MangaSource.LOCAL) return if (last.isLocal) return
val repo = mangaRepositoryFactory.create(last.source) val repo = mangaRepositoryFactory.create(last.source)
val details = runCatchingCancellable { repo.getDetails(last) }.getOrNull() ?: return val details = runCatchingCancellable { repo.getDetails(last) }.getOrNull() ?: return
val chapters = details.chapters val chapters = details.chapters
@@ -110,7 +112,7 @@ class MangaPrefetchService : CoroutineIntentService() {
} }
private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean { private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean {
if (source == MangaSource.LOCAL || context.isPowerSaveMode()) { if (source == LocalMangaSource || context.isPowerSaveMode()) {
return false return false
} }
val entryPoint = EntryPointAccessors.fromApplication( val entryPoint = EntryPointAccessors.fromApplication(

View File

@@ -43,6 +43,9 @@ import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.iconResId import org.koitharu.kotatsu.core.model.iconResId
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.titleResId import org.koitharu.kotatsu.core.model.titleResId
@@ -86,15 +89,14 @@ import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.image.ui.ImageActivity 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.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel 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.list.ui.size.StaticItemSizeResolver
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.ellipsize import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
@@ -120,10 +122,9 @@ class DetailsActivity :
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
@Inject @Inject
lateinit var tagHighlighter: ListExtraProvider lateinit var listMapper: MangaListMapper
private val viewModel: DetailsViewModel by viewModels() private val viewModel: DetailsViewModel by viewModels()
private lateinit var menuProvider: DetailsMenuProvider private lateinit var menuProvider: DetailsMenuProvider
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -157,6 +158,7 @@ class DetailsActivity :
viewBinding.containerBottomSheet?.let { sheet -> viewBinding.containerBottomSheet?.let { sheet ->
onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(sheet)) onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(sheet))
} }
TitleExpandListener(viewBinding.textViewTitle).attach()
viewModel.details.filterNotNull().observe(this, ::onMangaUpdated) viewModel.details.filterNotNull().observe(this, ::onMangaUpdated)
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved) viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
@@ -389,7 +391,7 @@ class DetailsActivity :
} }
} }
private fun onRelatedMangaChanged(related: List<MangaItemModel>) { private fun onRelatedMangaChanged(related: List<MangaListModel>) {
if (related.isEmpty()) { if (related.isEmpty()) {
viewBinding.groupRelated.isVisible = false viewBinding.groupRelated.isVisible = false
return return
@@ -463,10 +465,10 @@ class DetailsActivity :
imageViewState.isVisible = false imageViewState.isVisible = false
} }
if (manga.source == MangaSource.LOCAL || manga.source == MangaSource.DUMMY) { if (manga.source == LocalMangaSource || manga.source == UnknownMangaSource) {
infoLayout.chipSource.isVisible = false infoLayout.chipSource.isVisible = false
} else { } else {
infoLayout.chipSource.text = manga.source.title infoLayout.chipSource.text = manga.source.getTitle(this@DetailsActivity)
infoLayout.chipSource.isVisible = true infoLayout.chipSource.isVisible = true
} }
@@ -538,7 +540,7 @@ class DetailsActivity :
} }
val isFirstCall = buttonRead.tag == null val isFirstCall = buttonRead.tag == null
buttonRead.tag = Unit buttonRead.tag = Unit
buttonRead.setProgress(info.history?.percent?.coerceIn(0f, 1f) ?: 0f, !isFirstCall) buttonRead.setProgress(info.percent.coerceIn(0f, 1f), !isFirstCall)
buttonDownload?.isEnabled = info.isValid && info.canDownload buttonDownload?.isEnabled = info.isValid && info.canDownload
buttonRead.isEnabled = info.isValid buttonRead.isEnabled = info.isValid
} }
@@ -611,15 +613,7 @@ class DetailsActivity :
private fun bindTags(manga: Manga) { private fun bindTags(manga: Manga) {
viewBinding.chipsTags.isVisible = manga.tags.isNotEmpty() viewBinding.chipsTags.isVisible = manga.tags.isNotEmpty()
viewBinding.chipsTags.setChips( viewBinding.chipsTags.setChips(listMapper.mapTags(manga.tags))
manga.tags.map { tag ->
ChipsView.ChipModel(
title = tag.title,
tint = tagHighlighter.getTagTint(tag),
data = tag,
)
},
)
} }
private fun loadCover(manga: Manga) { private fun loadCover(manga: Manga) {

View File

@@ -16,11 +16,12 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
@@ -38,10 +39,10 @@ class DetailsMenuProvider(
override fun onPrepareMenu(menu: Menu) { override fun onPrepareMenu(menu: Menu) {
val manga = viewModel.manga.value val manga = viewModel.manga.value
menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != MangaSource.LOCAL menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != LocalMangaSource
menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL menu.findItem(R.id.action_delete).isVisible = manga?.source == LocalMangaSource
menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL menu.findItem(R.id.action_browser).isVisible = manga?.source != LocalMangaSource
menu.findItem(R.id.action_alternatives).isVisible = manga?.source != MangaSource.LOCAL menu.findItem(R.id.action_alternatives).isVisible = manga?.source != LocalMangaSource
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity) menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null
@@ -53,7 +54,7 @@ class DetailsMenuProvider(
R.id.action_share -> { R.id.action_share -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
val shareHelper = ShareHelper(activity) val shareHelper = ShareHelper(activity)
if (it.source == MangaSource.LOCAL) { if (it.isLocal) {
shareHelper.shareCbz(listOf(it.url.toUri().toFile())) shareHelper.shareCbz(listOf(it.url.toUri().toFile()))
} else { } else {
shareHelper.shareMangaLink(it) shareHelper.shareMangaLink(it)

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.details.ui.model.MangaBranch
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.model.MangaItemModel import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
@@ -76,7 +75,7 @@ class DetailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
private val relatedMangaUseCase: RelatedMangaUseCase, private val relatedMangaUseCase: RelatedMangaUseCase,
private val extraProvider: ListExtraProvider, private val mangaListMapper: MangaListMapper,
private val detailsLoadUseCase: DetailsLoadUseCase, private val detailsLoadUseCase: DetailsLoadUseCase,
private val progressUpdateUseCase: ProgressUpdateUseCase, private val progressUpdateUseCase: ProgressUpdateUseCase,
private val readingTimeUseCase: ReadingTimeUseCase, private val readingTimeUseCase: ReadingTimeUseCase,
@@ -93,15 +92,19 @@ class DetailsViewModel @Inject constructor(
val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) }) val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) })
val manga = details.map { x -> x?.toManga() } val manga = details.map { x -> x?.toManga() }
.withErrorHandling()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val history = historyRepository.observeOne(mangaId) val history = historyRepository.observeOne(mangaId)
.withErrorHandling()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val favouriteCategories = interactor.observeFavourite(mangaId) val favouriteCategories = interactor.observeFavourite(mangaId)
.withErrorHandling()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptySet()) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptySet())
val isStatsAvailable = statsRepository.observeHasStats(mangaId) val isStatsAvailable = statsRepository.observeHasStats(mangaId)
.withErrorHandling()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
val remoteManga = MutableStateFlow<Manga?>(null) val remoteManga = MutableStateFlow<Manga?>(null)
@@ -162,14 +165,17 @@ class DetailsViewModel @Inject constructor(
val onMangaRemoved = MutableEventFlow<Manga>() val onMangaRemoved = MutableEventFlow<Manga>()
val isScrobblingAvailable: Boolean val isScrobblingAvailable: Boolean
get() = scrobblers.any { it.isAvailable } get() = scrobblers.any { it.isEnabled }
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId) val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val relatedManga: StateFlow<List<MangaItemModel>> = manga.mapLatest { val relatedManga: StateFlow<List<MangaListModel>> = manga.mapLatest {
if (it != null && settings.isRelatedMangaEnabled) { if (it != null && settings.isRelatedMangaEnabled) {
relatedMangaUseCase.invoke(it)?.toUi(ListMode.GRID, extraProvider).orEmpty() mangaListMapper.toListModelList(
manga = relatedMangaUseCase(it).orEmpty(),
mode = ListMode.GRID,
)
} else { } else {
emptyList() emptyList()
} }
@@ -393,7 +399,7 @@ class DetailsViewModel @Inject constructor(
private fun getScrobbler(index: Int): Scrobbler? { private fun getScrobbler(index: Int): Scrobbler? {
val info = scrobblingInfo.value.getOrNull(index) val info = scrobblingInfo.value.getOrNull(index)
val scrobbler = if (info != null) { val scrobbler = if (info != null) {
scrobblers.find { it.scrobblerService == info.scrobbler && it.isAvailable } scrobblers.find { it.scrobblerService == info.scrobbler && it.isEnabled }
} else { } else {
null null
} }

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

@@ -16,6 +16,13 @@ data class HistoryInfo(
val canContinue val canContinue
get() = currentChapter >= 0 get() = currentChapter >= 0
val percent: Float
get() = if (history != null && (canContinue || isChapterMissing)) {
history.percent
} else {
0f
}
} }
fun HistoryInfo( fun HistoryInfo(
@@ -24,7 +31,11 @@ fun HistoryInfo(
history: MangaHistory?, history: MangaHistory?,
isIncognitoMode: Boolean isIncognitoMode: Boolean
): HistoryInfo { ): 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()) { val currentChapter = if (history != null && !chapters.isNullOrEmpty()) {
chapters.indexOfFirst { it.id == history.chapterId } chapters.indexOfFirst { it.id == history.chapterId }
} else { } else {

View File

@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
@@ -31,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.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.toCollection
import org.koitharu.kotatsu.core.util.ext.toSet
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.DetailsViewModel import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
@@ -40,7 +43,6 @@ import org.koitharu.kotatsu.details.ui.withVolumeHeaders
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
@@ -137,10 +139,10 @@ class ChaptersFragment :
val ids = selectionController?.peekCheckedIds() val ids = selectionController?.peekCheckedIds()
val manga = viewModel.manga.value val manga = viewModel.manga.value
when { when {
ids.isNullOrEmpty() || manga == null -> Unit ids == null || ids.isEmpty() || manga == null -> Unit
ids.size == manga.chapters?.size -> viewModel.deleteLocal() ids.size == manga.chapters?.size -> viewModel.deleteLocal()
else -> { else -> {
LocalChaptersRemoveService.start(requireContext(), manga, ids) LocalChaptersRemoveService.start(requireContext(), manga, ids.toSet())
Snackbar.make( Snackbar.make(
requireViewBinding().recyclerViewChapters, requireViewBinding().recyclerViewChapters,
R.string.chapters_will_removed_background, R.string.chapters_will_removed_background,
@@ -154,7 +156,7 @@ class ChaptersFragment :
R.id.action_select_range -> { R.id.action_select_range -> {
val items = chaptersAdapter?.items ?: return false val items = chaptersAdapter?.items ?: return false
val ids = HashSet(controller.peekCheckedIds()) val ids = controller.peekCheckedIds().toCollection(HashSet())
val buffer = HashSet<Long>() val buffer = HashSet<Long>()
var isAdding = false var isAdding = false
for (x in items) { for (x in items) {
@@ -188,8 +190,12 @@ class ChaptersFragment :
} }
R.id.action_mark_current -> { R.id.action_mark_current -> {
val id = controller.peekCheckedIds().singleOrNull() ?: return false val ids = controller.peekCheckedIds()
viewModel.markChapterAsCurrent(id) if (ids.size == 1) {
viewModel.markChapterAsCurrent(ids.first())
} else {
return false
}
mode.finish() mode.finish()
true true
} }
@@ -218,7 +224,7 @@ class ChaptersFragment :
var canSave = true var canSave = true
var canDelete = true var canDelete = true
items.forEach { (_, x) -> items.forEach { (_, x) ->
val isLocal = x.isDownloaded || x.chapter.source == MangaSource.LOCAL val isLocal = x.isDownloaded || x.chapter.source == LocalMangaSource
if (isLocal) canSave = false else canDelete = false if (isLocal) canSave = false else canDelete = false
} }
menu.findItem(R.id.action_save).isVisible = canSave menu.findItem(R.id.action_save).isVisible = canSave

View File

@@ -92,7 +92,7 @@ class MangaPageFetcher(
} }
else -> { else -> {
val request = PageLoader.createPageRequest(page, pageUrl) val request = PageLoader.createPageRequest(pageUrl, page.source)
imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response -> imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response ->
if (!response.isSuccessful) { if (!response.isSuccessful) {
throw HttpException(response) 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.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker 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.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject import javax.inject.Inject
@@ -34,7 +33,7 @@ class RelatedListViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory, mangaRepositoryFactory: MangaRepository.Factory,
settings: AppSettings, settings: AppSettings,
private val extraProvider: ListExtraProvider, private val mangaListMapper: MangaListMapper,
downloadScheduler: DownloadWorker.Scheduler, downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler) { ) : MangaListViewModel(settings, downloadScheduler) {
@@ -46,14 +45,14 @@ class RelatedListViewModel @Inject constructor(
override val content = combine( override val content = combine(
mangaList, mangaList,
listMode, observeListModeWithTriggers(),
listError, listError,
) { list, mode, error -> ) { list, mode, error ->
when { when {
list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true)) list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true))
list == null -> listOf(LoadingState) list == null -> listOf(LoadingState)
list.isEmpty() -> listOf(createEmptyState()) list.isEmpty() -> listOf(createEmptyState())
else -> list.toUi(mode, extraProvider) else -> mangaListMapper.toListModelList(list, mode)
} }
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.download.ui.list package org.koitharu.kotatsu.download.ui.list
import androidx.collection.ArrayMap import androidx.collection.ArrayMap
import androidx.collection.LongSet
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import androidx.collection.getOrElse import androidx.collection.getOrElse
import androidx.collection.set 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.model.formatNumber
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.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.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
@@ -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() 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) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
private suspend fun tryLoad(manga: Manga) = runCatchingCancellable { private suspend fun tryLoad(manga: Manga) = runCatchingCancellable {
(mangaRepositoryFactory.create(manga.source) as RemoteMangaRepository).getDetails(manga) (mangaRepositoryFactory.create(manga.source) as ParserMangaRepository).getDetails(manga)
}.getOrNull() }.getOrNull()
} }

View File

@@ -21,13 +21,13 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
@@ -231,7 +231,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
if (manga != null) { if (manga != null) {
DetailsActivity.newIntent(context, manga) DetailsActivity.newIntent(context, manga)
} else { } else {
MangaListActivity.newIntent(context, MangaSource.LOCAL) MangaListActivity.newIntent(context, LocalMangaSource)
}, },
PendingIntent.FLAG_CANCEL_CURRENT, PendingIntent.FLAG_CANCEL_CURRENT,
false, false,

View File

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

View File

@@ -35,16 +35,17 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import okio.IOException import okio.IOException
import okio.buffer import okio.buffer
import okio.sink import okio.sink
import okio.use
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.network.MangaHttpClient 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.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -55,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.deleteAwait
import org.koitharu.kotatsu.core.util.ext.deleteWork import org.koitharu.kotatsu.core.util.ext.deleteWork
import org.koitharu.kotatsu.core.util.ext.deleteWorks import org.koitharu.kotatsu.core.util.ext.deleteWorks
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getWorkInputData import org.koitharu.kotatsu.core.util.ext.getWorkInputData
import org.koitharu.kotatsu.core.util.ext.getWorkSpec import org.koitharu.kotatsu.core.util.ext.getWorkSpec
@@ -73,9 +75,9 @@ import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.domain.PageLoader
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -93,6 +95,7 @@ class DownloadWorker @AssistedInject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val settings: AppSettings, private val settings: AppSettings,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
private val imageProxyInterceptor: ImageProxyInterceptor,
notificationFactoryFactory: DownloadNotificationFactory.Factory, notificationFactoryFactory: DownloadNotificationFactory.Factory,
) : CoroutineWorker(appContext, params) { ) : CoroutineWorker(appContext, params) {
@@ -177,7 +180,7 @@ class DownloadWorker @AssistedInject constructor(
checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) } checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) }
var output: LocalMangaOutput? = null var output: LocalMangaOutput? = null
try { try {
if (manga.source == MangaSource.LOCAL) { if (manga.isLocal) {
manga = localMangaRepository.getRemoteManga(manga) manga = localMangaRepository.getRemoteManga(manga)
?: error("Cannot obtain remote manga instance") ?: error("Cannot obtain remote manga instance")
} }
@@ -327,28 +330,24 @@ class DownloadWorker @AssistedInject constructor(
destination: File, destination: File,
source: MangaSource, source: MangaSource,
): File { ): File {
val request = Request.Builder() val request = PageLoader.createPageRequest(url, source)
.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()
slowdownDispatcher.delay(source) slowdownDispatcher.delay(source)
val call = okHttp.newCall(request) return imageProxyInterceptor.interceptPageRequest(request, okHttp)
val file = File(destination, UUID.randomUUID().toString() + ".tmp") .ensureSuccess()
try { .use { response ->
val response = call.clone().await() val file = File(destination, UUID.randomUUID().toString() + ".tmp")
checkNotNull(response.body).use { body -> try {
file.sink(append = false).buffer().use { checkNotNull(response.body).use { body ->
it.writeAllCancellable(body.source()) file.sink(append = false).buffer().use {
it.writeAllCancellable(body.source())
}
}
} catch (e: CancellationException) {
file.delete()
throw e
} }
file
} }
} catch (e: CancellationException) {
file.delete()
throw e
}
return file
} }
private suspend fun publishState(state: DownloadState) { private suspend fun publishState(state: DownloadState) {

View File

@@ -1,8 +1,16 @@
package org.koitharu.kotatsu.explore.data 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 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.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
@@ -12,20 +20,27 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity 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.model.isNsfw
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import java.util.Collections import java.util.Collections
import java.util.EnumSet import java.util.EnumSet
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@Reusable @Singleton
class MangaSourcesRepository @Inject constructor( class MangaSourcesRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val db: MangaDatabase, private val db: MangaDatabase,
private val settings: AppSettings, private val settings: AppSettings,
) { ) {
@@ -34,20 +49,38 @@ class MangaSourcesRepository @Inject constructor(
private val dao: MangaSourcesDao private val dao: MangaSourcesDao
get() = db.getSourcesDao() get() = db.getSourcesDao()
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply { private val remoteSources = EnumSet.allOf(MangaParserSource::class.java).apply {
remove(MangaSource.LOCAL)
if (!BuildConfig.DEBUG) { if (!BuildConfig.DEBUG) {
remove(MangaSource.DUMMY) remove(MangaParserSource.DUMMY)
} }
} }
val allMangaSources: Set<MangaSource> val allMangaSources: Set<MangaParserSource>
get() = Collections.unmodifiableSet(remoteSources) get() = Collections.unmodifiableSet(remoteSources)
suspend fun getEnabledSources(): List<MangaSource> { suspend fun getEnabledSources(): List<MangaSource> {
assimilateNewSources() assimilateNewSources()
val order = settings.sourcesSortOrder 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> { suspend fun getDisabledSources(): Set<MangaSource> {
@@ -61,7 +94,7 @@ class MangaSourcesRepository @Inject constructor(
return result return result
} }
suspend fun getAvailableSources( suspend fun queryParserSources(
isDisabledOnly: Boolean, isDisabledOnly: Boolean,
isNewOnly: Boolean, isNewOnly: Boolean,
excludeBroken: Boolean, excludeBroken: Boolean,
@@ -69,7 +102,7 @@ class MangaSourcesRepository @Inject constructor(
query: String?, query: String?,
locale: String?, locale: String?,
sortOrder: SourcesSortOrder?, sortOrder: SourcesSortOrder?,
): List<MangaSource> { ): List<MangaParserSource> {
assimilateNewSources() assimilateNewSources()
val entities = dao.findAll().toMutableList() val entities = dao.findAll().toMutableList()
if (isDisabledOnly) { if (isDisabledOnly) {
@@ -81,7 +114,9 @@ class MangaSourcesRepository @Inject constructor(
val sources = entities.toSources( val sources = entities.toSources(
skipNsfwSources = settings.isNsfwContentDisabled, skipNsfwSources = settings.isNsfwContentDisabled,
sortOrder = sortOrder, sortOrder = sortOrder,
) ).run {
mapNotNullTo(ArrayList(size)) { it.mangaSource as? MangaParserSource }
}
if (locale != null) { if (locale != null) {
sources.retainAll { it.locale == locale } sources.retainAll { it.locale == locale }
} }
@@ -93,7 +128,7 @@ class MangaSourcesRepository @Inject constructor(
} }
if (!query.isNullOrEmpty()) { if (!query.isNullOrEmpty()) {
sources.retainAll { 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 return sources
@@ -126,14 +161,21 @@ class MangaSourcesRepository @Inject constructor(
}.distinctUntilChanged().onStart { assimilateNewSources() } }.distinctUntilChanged().onStart { assimilateNewSources() }
} }
fun observeEnabledSources(): Flow<List<MangaSource>> = combine( fun observeEnabledSources(): Flow<List<MangaSourceInfo>> = combine(
observeIsNsfwDisabled(), observeIsNsfwDisabled(),
observeSortOrder(), observeSortOrder(),
) { skipNsfw, order -> ) { skipNsfw, order ->
dao.observeEnabled(order).map { dao.observeEnabled(order).map {
it.toSources(skipNsfw, order) 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 -> fun observeAll(): Flow<List<Pair<MangaSource, Boolean>>> = dao.observeAll().map { entities ->
val result = ArrayList<Pair<MangaSource, Boolean>>(entities.size) val result = ArrayList<Pair<MangaSource, Boolean>>(entities.size)
@@ -213,6 +255,8 @@ class MangaSourcesRepository @Inject constructor(
isEnabled = false, isEnabled = false,
sortKey = ++maxSortKey, sortKey = ++maxSortKey,
addedIn = BuildConfig.VERSION_CODE, addedIn = BuildConfig.VERSION_CODE,
lastUsedAt = 0,
isPinned = false,
) )
} }
dao.insertIfAbsent(entities) dao.insertIfAbsent(entities)
@@ -223,6 +267,19 @@ class MangaSourcesRepository @Inject constructor(
return settings.sourcesVersion == 0 && dao.findAllEnabledNames().isEmpty() 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) { private suspend fun setSourcesEnabledImpl(sources: Collection<MangaSource>, isEnabled: Boolean) {
if (sources.size == 1) { // fast path if (sources.size == 1) { // fast path
dao.setEnabled(sources.first().name, isEnabled) dao.setEnabled(sources.first().name, isEnabled)
@@ -235,7 +292,7 @@ class MangaSourcesRepository @Inject constructor(
} }
} }
private suspend fun getNewSources(): MutableSet<MangaSource> { private suspend fun getNewSources(): MutableSet<out MangaSource> {
val entities = dao.findAll() val entities = dao.findAll()
val result = EnumSet.copyOf(remoteSources) val result = EnumSet.copyOf(remoteSources)
for (e in entities) { for (e in entities) {
@@ -244,22 +301,77 @@ class MangaSourcesRepository @Inject constructor(
return result 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( private fun List<MangaSourceEntity>.toSources(
skipNsfwSources: Boolean, skipNsfwSources: Boolean,
sortOrder: SourcesSortOrder?, sortOrder: SourcesSortOrder?,
): MutableList<MangaSource> { ): MutableList<MangaSourceInfo> {
val result = ArrayList<MangaSource>(size) val result = ArrayList<MangaSourceInfo>(size)
for (entity in this) { for (entity in this) {
val source = entity.source.toMangaSourceOrNull() ?: continue val source = entity.source.toMangaSourceOrNull() ?: continue
if (skipNsfwSources && source.isNsfw()) { if (skipNsfwSources && source.isNsfw()) {
continue continue
} }
if (source in remoteSources) { if (source in remoteSources) {
result.add(source) result.add(
MangaSourceInfo(
mangaSource = source,
isEnabled = entity.isEnabled,
isPinned = entity.isPinned,
),
)
} }
} }
if (sortOrder == SourcesSortOrder.ALPHABETIC) { if (sortOrder == SourcesSortOrder.ALPHABETIC) {
result.sortBy { it.title } result.sortWith(compareBy<MangaSourceInfo> { !it.isPinned }.thenBy { it.getTitle(context) })
} }
return result return result
} }
@@ -272,5 +384,5 @@ class MangaSourcesRepository @Inject constructor(
sourcesSortOrder sourcesSortOrder
} }
private fun String.toMangaSourceOrNull(): MangaSource? = MangaSource.entries.find { it.name == this } private fun String.toMangaSourceOrNull(): MangaParserSource? = MangaParserSource.entries.find { it.name == this }
} }

View File

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

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.explore.domain package org.koitharu.kotatsu.explore.domain
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.almostEquals import org.koitharu.kotatsu.core.util.ext.almostEquals
@@ -7,7 +8,6 @@ import org.koitharu.kotatsu.core.util.ext.asArrayList
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -45,7 +45,7 @@ class ExploreRepository @Inject constructor(
suspend fun findRandomManga(source: MangaSource, tagsLimit: Int): Manga { suspend fun findRandomManga(source: MangaSource, tagsLimit: Int): Manga {
val tagsBlacklist = TagsBlacklist(settings.suggestionsTagsBlacklist, 0.4f) val tagsBlacklist = TagsBlacklist(settings.suggestionsTagsBlacklist, 0.4f)
val skipNsfw = settings.isSuggestionsExcludeNsfw && source.contentType != ContentType.HENTAI val skipNsfw = settings.isSuggestionsExcludeNsfw && !source.isNsfw()
val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull { val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull {
if (it in tagsBlacklist) null else it.title if (it in tagsBlacklist) null else it.title
} }

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.explore.ui
import android.content.DialogInterface import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
@@ -20,6 +22,8 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver 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.BaseFragment
import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
@@ -40,8 +44,7 @@ import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
@@ -123,7 +126,7 @@ class ExploreFragment :
override fun onClick(v: View) { override fun onClick(v: View) {
val intent = when (v.id) { val intent = when (v.id) {
R.id.button_local -> MangaListActivity.newIntent(v.context, MangaSource.LOCAL) R.id.button_local -> MangaListActivity.newIntent(v.context, LocalMangaSource)
R.id.button_bookmarks -> AllBookmarksActivity.newIntent(v.context) R.id.button_bookmarks -> AllBookmarksActivity.newIntent(v.context)
R.id.button_more -> SuggestionsActivity.newIntent(v.context) R.id.button_more -> SuggestionsActivity.newIntent(v.context)
R.id.button_downloads -> DownloadsActivity.newIntent(v.context) R.id.button_downloads -> DownloadsActivity.newIntent(v.context)
@@ -165,16 +168,19 @@ class ExploreFragment :
} }
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { 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_settings).isVisible = isSingleSelection
menu.findItem(R.id.action_shortcut).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) return super.onPrepareActionMode(controller, mode, menu)
} }
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
val selectedSources = controller.peekCheckedIds().mapNotNullToSet { id -> val selectedSources = viewModel.sourcesSnapshot(controller.peekCheckedIds())
MangaSource.entries.getOrNull(id.toInt())
}
if (selectedSources.isEmpty()) { if (selectedSources.isEmpty()) {
return false return false
} }
@@ -190,12 +196,29 @@ class ExploreFragment :
mode.finish() mode.finish()
} }
R.id.action_delete -> {
selectedSources.forEach {
(it.mangaSource as? ExternalMangaSource)?.let { uninstallExternalSource(it) }
}
mode.finish()
}
R.id.action_shortcut -> { R.id.action_shortcut -> {
val source = selectedSources.singleOrNull() ?: return false val source = selectedSources.singleOrNull() ?: return false
viewModel.requestPinShortcut(source) viewModel.requestPinShortcut(source)
mode.finish() 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 else -> return false
} }
return true return true
@@ -228,4 +251,14 @@ class ExploreFragment :
.create() .create()
.show() .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 package org.koitharu.kotatsu.explore.ui
import androidx.collection.LongSet
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -13,6 +14,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
@@ -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.ExploreButtons
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
import org.koitharu.kotatsu.explore.ui.model.RecommendationsItem 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.EmptyHint
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -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) { fun respondSuggestionTip(isAccepted: Boolean) {
settings.isSuggestionsEnabled = isAccepted settings.isSuggestionsEnabled = isAccepted
settings.closeTip(TIP_SUGGESTIONS) 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( private fun createContentFlow() = combine(
sourcesRepository.observeEnabledSources(), sourcesRepository.observeEnabledSources(),
getSuggestionFlow(), getSuggestionFlow(),
@@ -124,7 +143,7 @@ class ExploreViewModel @Inject constructor(
}.withErrorHandling() }.withErrorHandling()
private fun buildList( private fun buildList(
sources: List<MangaSource>, sources: List<MangaSourceInfo>,
recommendation: List<Manga>, recommendation: List<Manga>,
isGrid: Boolean, isGrid: Boolean,
randomLoading: Boolean, randomLoading: Boolean,
@@ -170,14 +189,15 @@ class ExploreViewModel @Inject constructor(
} }
private fun List<Manga>.toRecommendationList() = map { manga -> private fun List<Manga>.toRecommendationList() = map { manga ->
MangaListModel( MangaCompactListModel(
id = manga.id, id = manga.id,
title = manga.title, title = manga.title,
subtitle = manga.tags.joinToString { it.title }, subtitle = manga.tags.joinToString { it.title },
coverUrl = manga.coverUrl, coverUrl = manga.coverUrl,
manga = manga, manga = manga,
counter = 0, counter = 0,
progress = PROGRESS_NONE, progress = null,
isFavorite = false,
) )
} }

View File

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

View File

@@ -1,15 +1,15 @@
package org.koitharu.kotatsu.explore.ui.model 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.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaSource
data class MangaSourceItem( data class MangaSourceItem(
val source: MangaSource, val source: MangaSourceInfo,
val isGrid: Boolean, val isGrid: Boolean,
) : ListModel { ) : ListModel {
val id: Long val id: Long = source.name.longHashCode()
get() = source.ordinal.toLong()
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {
return other is MangaSourceItem && other.source == source return other is MangaSourceItem && other.source == source

View File

@@ -1,10 +1,10 @@
package org.koitharu.kotatsu.explore.ui.model package org.koitharu.kotatsu.explore.ui.model
import org.koitharu.kotatsu.list.ui.model.ListModel 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( data class RecommendationsItem(
val manga: List<MangaListModel> val manga: List<MangaCompactListModel>
) : ListModel { ) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean { 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") @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> 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 **/
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)

View File

@@ -115,6 +115,10 @@ class FavouritesRepository @Inject constructor(
return db.getFavouriteCategoriesDao().find(id.toInt()).toFavouriteCategory() 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> { suspend fun getCategoriesIds(mangaIds: Collection<Long>): Set<Long> {
return db.getFavouritesDao().findCategoriesIds(mangaIds).toSet() return db.getFavouritesDao().findCategoriesIds(mangaIds).toSet()
} }

View File

@@ -1,12 +1,10 @@
package org.koitharu.kotatsu.favourites.domain.model package org.koitharu.kotatsu.favourites.domain.model
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.util.find
data class Cover( data class Cover(
val url: String, val url: String,
val source: String, val source: String,
) { ) {
val mangaSource: MangaSource? val mangaSource by lazy { MangaSource(source) }
get() = if (source.isEmpty()) null else MangaSource.entries.find(source)
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.favourites.ui.categories package org.koitharu.kotatsu.favourites.ui.categories
import androidx.collection.LongSet
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers 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() val items = content.requireValue()
return items.mapNotNullTo(ArrayList(ids.size)) { item -> return items.mapNotNullTo(ArrayList(ids.size)) { item ->
(item as? CategoryListModel)?.category?.takeIf { it.id in ids } (item as? CategoryListModel)?.category?.takeIf { it.id in ids }

View File

@@ -10,13 +10,13 @@ import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource
@AndroidEntryPoint @AndroidEntryPoint
class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener { class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener {
@@ -57,9 +57,7 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
} }
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { it.isLocal }
it.source == MangaSource.LOCAL
}
return super.onPrepareActionMode(controller, mode, menu) return super.onPrepareActionMode(controller, mode, menu)
} }

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

View File

@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.widgets.ChipsView 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.asArrayList
import org.koitharu.kotatsu.core.util.ext.lifecycleScope import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug 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.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
@@ -43,6 +43,7 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorFooter import org.koitharu.kotatsu.list.ui.model.toErrorFooter
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -66,7 +67,7 @@ class FilterCoordinator @Inject constructor(
) : MangaFilter { ) : MangaFilter {
private val coroutineScope = lifecycle.lifecycleScope 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( private val currentState = MutableStateFlow(
MangaListFilter.Advanced( MangaListFilter.Advanced(
sortOrder = repository.defaultSortOrder, sortOrder = repository.defaultSortOrder,
@@ -451,7 +452,7 @@ class FilterCoordinator @Inject constructor(
} }
private fun mergeTags(primary: Set<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> { private fun mergeTags(primary: Set<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> {
val result = TreeSet(TagTitleComparator(repository.source.locale)) val result = TreeSet(TagTitleComparator((repository.source as? MangaParserSource)?.locale))
result.addAll(secondary) result.addAll(secondary)
result.addAll(primary) result.addAll(primary)
return result return result

View File

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

View File

@@ -8,6 +8,7 @@ import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.os.NetworkManageIntent import org.koitharu.kotatsu.core.os.NetworkManageIntent
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
@@ -17,7 +18,6 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
import org.koitharu.kotatsu.parsers.model.MangaSource
@AndroidEntryPoint @AndroidEntryPoint
class HistoryListFragment : MangaListFragment() { class HistoryListFragment : MangaListFragment() {
@@ -44,9 +44,7 @@ class HistoryListFragment : MangaListFragment() {
} }
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { it.isLocal }
it.source == MangaSource.LOCAL
}
return super.onPrepareActionMode(controller, mode, menu) return super.onPrepareActionMode(controller, mode, menu)
} }

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

View File

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

View File

@@ -11,8 +11,14 @@ import android.view.animation.AccelerateDecelerateInterpolator
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.StyleRes import androidx.annotation.StyleRes
import org.koitharu.kotatsu.R 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.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.history.data.PROGRESS_NONE import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ReadingProgress
class ReadingProgressView @JvmOverloads constructor( class ReadingProgressView @JvmOverloads constructor(
context: Context, context: Context,
@@ -20,17 +26,30 @@ class ReadingProgressView @JvmOverloads constructor(
@AttrRes defStyleAttr: Int = 0, @AttrRes defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener, Animator.AnimatorListener { ) : View(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener, Animator.AnimatorListener {
private val percentPattern = context.getString(R.string.percent_string_pattern)
private var percentAnimator: ValueAnimator? = null private var percentAnimator: ValueAnimator? = null
private val animationDuration = context.getAnimationDuration(android.R.integer.config_shortAnimTime) private val animationDuration = context.getAnimationDuration(android.R.integer.config_shortAnimTime)
@StyleRes @StyleRes
private val drawableStyle: Int private val drawableStyle: Int
var percent: Float var progress: ReadingProgress? = null
get() = peekProgressDrawable()?.progress ?: PROGRESS_NONE
set(value) { set(value) {
field = value
cancelAnimation() 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 { init {
@@ -39,7 +58,11 @@ class ReadingProgressView @JvmOverloads constructor(
ta.recycle() ta.recycle()
outlineProvider = OutlineProvider() outlineProvider = OutlineProvider()
if (isInEditMode) { 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) { override fun onAnimationUpdate(animation: ValueAnimator) {
val p = animation.animatedValue as Float val p = animation.animatedValue as Float
getProgressDrawable().progress = p getProgressDrawable().percent = p
} }
override fun onAnimationStart(animation: Animator) = Unit override fun onAnimationStart(animation: Animator) = Unit
@@ -68,16 +91,25 @@ class ReadingProgressView @JvmOverloads constructor(
override fun onAnimationRepeat(animation: Animator) = Unit 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() val currentDrawable = peekProgressDrawable()
if (!animate || currentDrawable == null || value == PROGRESS_NONE) { if (!animate || currentDrawable == null || value == null) {
percent = value progress = value
return return
} }
percentAnimator?.cancel() percentAnimator?.cancel()
val currentPercent = currentDrawable.percent.coerceAtLeast(0f)
progress = value.copy(percent = currentPercent)
percentAnimator = ValueAnimator.ofFloat( percentAnimator = ValueAnimator.ofFloat(
currentDrawable.progress.coerceAtLeast(0f), currentDrawable.percent.coerceAtLeast(0f),
value, value.percent,
).apply { ).apply {
duration = animationDuration duration = animationDuration
interpolator = AccelerateDecelerateInterpolator() interpolator = AccelerateDecelerateInterpolator()

View File

@@ -25,13 +25,13 @@ import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.util.PopupMenuMediator import org.koitharu.kotatsu.core.ui.util.PopupMenuMediator
import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getDisplayIcon import org.koitharu.kotatsu.core.util.ext.getDisplayIcon
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
@@ -120,7 +120,7 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
.memoryCachePolicy(CachePolicy.DISABLED) .memoryCachePolicy(CachePolicy.DISABLED)
.lifecycle(this) .lifecycle(this)
.listener(this) .listener(this)
.source(intent.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)) .source(MangaSource(intent.getStringExtra(EXTRA_SOURCE)))
.target(SsivTarget(viewBinding.ssiv)) .target(SsivTarget(viewBinding.ssiv))
.enqueueWith(coil) .enqueueWith(coil)
} }
@@ -180,7 +180,7 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
fun newIntent(context: Context, url: String, source: MangaSource?): Intent { fun newIntent(context: Context, url: String, source: MangaSource?): Intent {
return Intent(context, ImageActivity::class.java) return Intent(context, ImageActivity::class.java)
.setData(Uri.parse(url)) .setData(Uri.parse(url))
.putExtra(EXTRA_SOURCE, source) .putExtra(EXTRA_SOURCE, source?.name)
} }
} }
} }

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
}
}
}

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