Compare commits

..

355 Commits
v6.6.8 ... ui

Author SHA1 Message Date
Zakhar Timoshenko
f4c52654a7 UI adjust part 1 2024-04-08 01:31:22 +03:00
Zakhar Timoshenko
44b71460ee Fix read button coloring 2024-04-07 23:52:19 +03:00
Koitharu
265fbc9f63 UI improvements 2024-04-07 18:47:04 +03:00
Koitharu
7c4b254f08 UI improvements 2024-04-06 19:58:54 +03:00
Koitharu
1bf01ca240 Improve tracker part 2 2024-04-06 17:12:27 +03:00
Koitharu
54ff63dbc7 Improve tracker part 1 2024-04-06 16:12:59 +03:00
Koitharu
61ddee0bba New details activity and chapters sheet improvements 2024-04-04 11:16:51 +03:00
Koitharu
8174d236f6 Imrpove new chapters sheet 2024-04-03 11:23:53 +03:00
Koitharu
b27d5607ac New details activity 2024-04-03 07:40:01 +03:00
Koitharu
905f565766 Check backup format before restoring 2024-04-01 13:33:34 +03:00
Koitharu
b33c93290b Disable password saving for protect activity 2024-04-01 10:24:25 +03:00
Koitharu
5abb07fda2 Fix crash in BrowserActivity #835 2024-03-30 15:25:47 +02:00
Koitharu
b57069c55f Merge remote-tracking branch 'weblate/devel' into devel 2024-03-30 09:18:41 +02:00
Koitharu
5b1a4d3ff5 Update dependencies 2024-03-30 09:15:20 +02:00
Koitharu
2b26f944d0 Fix background color in webttoon mode #832 2024-03-30 09:02:12 +02:00
Koitharu
a15197f69d Update suggestions after config changes #831 2024-03-30 08:47:08 +02:00
Koitharu
41f64b2e36 Handle NoDataReceivedException 2024-03-30 08:21:47 +02:00
Koitharu
bec032c7dc Fix TransactionTooLargeException when using WebView 2024-03-30 08:13:02 +02:00
maryush
0ffefddb86 Translated using Weblate (Polish)
Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-03-23 16:38:21 +02:00
Anton Prevrhal
09b154c997 Translated using Weblate (German)
Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: Anton Prevrhal <anton.prevrhal@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2024-03-23 16:38:21 +02:00
jonathan | ヨナタン
d9f3b4f76e Translated using Weblate (German)
Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: jonathan | ヨナタン <jonathan.evertz@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2024-03-23 16:38:21 +02:00
Anon
8ebb3ef804 Translated using Weblate (Serbian)
Currently translated at 99.8% (635 of 636 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-03-23 16:38:21 +02:00
ReksaTresna
b03682a81f Translated using Weblate (Indonesian)
Currently translated at 95.1% (605 of 636 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: ReksaTresna <ilham151096@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/id/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-03-23 16:38:21 +02:00
Infy's Tagalog Translations
5dd54be06c Translated using Weblate (Filipino)
Currently translated at 100.0% (636 of 636 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-03-23 16:38:21 +02:00
Scrambled777
98c0b60207 Translated using Weblate (Hindi)
Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-03-23 16:38:21 +02:00
gekka
10a0009532 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (636 of 636 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (636 of 636 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-03-23 16:38:21 +02:00
Oğuz Ersen
5e203f0b27 Translated using Weblate (Turkish)
Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-03-23 16:38:21 +02:00
gallegonovato
46fc48cfd7 Translated using Weblate (Spanish)
Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-03-23 16:38:21 +02:00
Макар Разин
e8a17708d2 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (636 of 636 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (636 of 636 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (636 of 636 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (636 of 636 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (636 of 636 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (636 of 636 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-03-23 16:38:21 +02:00
maryush
061eaa2a56 Translated using Weblate (Polish)
Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-03-23 15:36:15 +01:00
Anton Prevrhal
bc6e29b562 Translated using Weblate (German)
Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: Anton Prevrhal <anton.prevrhal@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2024-03-23 15:36:15 +01:00
jonathan | ヨナタン
d8c1dcef29 Translated using Weblate (German)
Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: jonathan | ヨナタン <jonathan.evertz@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2024-03-23 15:36:15 +01:00
Anon
ca281afba1 Translated using Weblate (Serbian)
Currently translated at 99.8% (635 of 636 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-03-23 15:36:15 +01:00
ReksaTresna
cde07a60d7 Translated using Weblate (Indonesian)
Currently translated at 95.1% (605 of 636 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: ReksaTresna <ilham151096@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/id/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-03-23 15:36:15 +01:00
Infy's Tagalog Translations
e31af0f43f Translated using Weblate (Filipino)
Currently translated at 100.0% (638 of 638 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (636 of 636 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-03-23 15:36:15 +01:00
Scrambled777
15dd0f38e7 Translated using Weblate (Hindi)
Currently translated at 100.0% (638 of 638 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-03-23 15:36:15 +01:00
gekka
d93647e889 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (636 of 636 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (636 of 636 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-03-23 15:36:15 +01:00
Oğuz Ersen
509d9a2fba Translated using Weblate (Turkish)
Currently translated at 100.0% (638 of 638 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-03-23 15:36:15 +01:00
gallegonovato
879d05f1a6 Translated using Weblate (Spanish)
Currently translated at 100.0% (638 of 638 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-03-23 15:36:15 +01:00
Макар Разин
ecf6bbfb66 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (636 of 636 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (636 of 636 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (636 of 636 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (636 of 636 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (636 of 636 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (636 of 636 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-03-23 15:36:15 +01:00
Koitharu
bc42fda786 Update parsers 2024-03-23 16:36:01 +02:00
Koitharu
d3590372f3 Disable reporting of ParseException 2024-03-23 16:22:25 +02:00
Koitharu
88f55997fa Fix stats chart color 2024-03-23 16:18:01 +02:00
Koitharu
0a1bc6716b Fix crashes 2024-03-23 15:59:49 +02:00
Koitharu
559e546462 Fix favorites migration 2024-03-23 10:05:01 +02:00
Koitharu
6c5775a2ed Option to disable Pages tab on details screen 2024-03-23 09:54:10 +02:00
Koitharu
4858adbbe7 Fix chapters selection 2024-03-20 07:23:17 +02:00
Koitharu
cae07b2798 Update parsers 2024-03-19 13:38:48 +02:00
Koitharu
b14603c384 Fix history migration 2024-03-18 14:28:29 +02:00
Koitharu
2f21d0f0f8 Merge remote-tracking branch 'weblate/devel' into devel 2024-03-18 13:32:13 +02:00
Koitharu
7e182cb0ad Fix passing CloudFlare protection #819 #820 2024-03-18 13:18:24 +02:00
maryush
f79d2cb733 Translated using Weblate (Polish)
Currently translated at 100.0% (626 of 626 strings)

Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-03-15 16:09:07 +02:00
大王叫我来巡山
ce296900c5 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (629 of 629 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.4% (616 of 626 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-03-15 16:09:07 +02:00
Oğuz Ersen
0156ae86eb Translated using Weblate (Turkish)
Currently translated at 100.0% (634 of 634 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (629 of 629 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (626 of 626 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-03-15 16:09:07 +02:00
Scrambled777
efd82b6d96 Translated using Weblate (Hindi)
Currently translated at 100.0% (634 of 634 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (629 of 629 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (628 of 628 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (626 of 626 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (621 of 621 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-03-15 16:09:07 +02:00
Abay Emes
b4371d2cd2 Translated using Weblate (Kazakh)
Currently translated at 87.8% (541 of 616 strings)

Co-authored-by: Abay Emes <abayemes@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/kk/
Translation: Kotatsu/Strings
2024-03-15 16:09:07 +02:00
gallegonovato
676c94d759 Translated using Weblate (Spanish)
Currently translated at 100.0% (634 of 634 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (629 of 629 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (628 of 628 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (616 of 616 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-03-15 16:09:07 +02:00
Макар Разин
b4c8fb7f9b Translated using Weblate (Ukrainian)
Currently translated at 100.0% (621 of 621 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (621 of 621 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (621 of 621 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (616 of 616 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (616 of 616 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-03-15 16:09:07 +02:00
Anon
5f79d37506 Translated using Weblate (Serbian)
Currently translated at 99.8% (620 of 621 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (616 of 616 strings)

Translated using Weblate (Serbian)

Currently translated at 99.6% (614 of 616 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-03-15 16:09:07 +02:00
gekka
2e074573c0 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (634 of 634 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (634 of 634 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (629 of 629 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.6% (627 of 629 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.3% (612 of 616 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.3% (612 of 616 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-03-15 16:09:07 +02:00
maryush
82281312fb Translated using Weblate (Polish)
Currently translated at 100.0% (626 of 626 strings)

Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-03-15 14:52:35 +01:00
大王叫我来巡山
ed6a906459 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (629 of 629 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.4% (616 of 626 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-03-15 14:52:35 +01:00
Oğuz Ersen
00b01f298d Translated using Weblate (Turkish)
Currently translated at 100.0% (634 of 634 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (629 of 629 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (626 of 626 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-03-15 14:52:35 +01:00
Scrambled777
aa99ea1245 Translated using Weblate (Hindi)
Currently translated at 100.0% (634 of 634 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (629 of 629 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (628 of 628 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (626 of 626 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (621 of 621 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-03-15 14:52:35 +01:00
Abay Emes
732c614aad Translated using Weblate (Kazakh)
Currently translated at 87.8% (541 of 616 strings)

Co-authored-by: Abay Emes <abayemes@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/kk/
Translation: Kotatsu/Strings
2024-03-15 14:52:35 +01:00
gallegonovato
afe16859d4 Translated using Weblate (Spanish)
Currently translated at 100.0% (634 of 634 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (629 of 629 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (628 of 628 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (616 of 616 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-03-15 14:52:35 +01:00
Макар Разин
c95f2aa9a1 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (634 of 634 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (634 of 634 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (634 of 634 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (621 of 621 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (621 of 621 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (621 of 621 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (616 of 616 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (616 of 616 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-03-15 14:52:35 +01:00
Anon
630cece4f5 Translated using Weblate (Serbian)
Currently translated at 99.8% (620 of 621 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (616 of 616 strings)

Translated using Weblate (Serbian)

Currently translated at 99.6% (614 of 616 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-03-15 14:52:35 +01:00
gekka
f0101bc183 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (634 of 634 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (634 of 634 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (629 of 629 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.6% (627 of 629 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.3% (612 of 616 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.3% (612 of 616 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-03-15 14:52:35 +01:00
Koitharu
7c2829226d Improve alternatives search 2024-03-15 15:52:15 +02:00
Koitharu
83bd390c2a Add enable toggle to source settings 2024-03-15 11:34:10 +02:00
Koitharu
b090652007 UI updates 2024-03-15 11:02:39 +02:00
Koitharu
569edd91c9 Fix manga migration 2024-03-15 08:09:51 +02:00
Koitharu
2e81684652 Fix chapters grid view 2024-03-14 14:08:22 +02:00
Koitharu
2573d150f9 Invalidate manga cache on config changes 2024-03-14 13:44:13 +02:00
Koitharu
24fe83aa5c Added more sort orders for local lists #707 #467 2024-03-14 12:51:50 +02:00
Koitharu
bbc39becc3 Update dependencies 2024-03-14 10:08:02 +02:00
Koitharu
65077c1fba Update chapters grid ui 2024-03-13 15:51:33 +02:00
Koitharu
bec0ce2c96 Refactor chapters grid mode 2024-03-13 14:46:47 +02:00
Koitharu
256f0a31bc Merge branch 'feature/grid-chapters' of github.com:jsericksk/Kotatsu into jsericksk-feature/grid-chapters 2024-03-13 13:02:29 +02:00
Koitharu
b8e48d8b8a Option to automatically clean read chapters 2024-03-13 10:06:39 +02:00
Koitharu
8313d6966f Action to remove read local chapters 2024-03-12 18:04:45 +02:00
Koitharu
7e581a5ed7 Fix local chapters deletion 2024-03-12 15:02:39 +02:00
Koitharu
16027e3295 Manga migration feature 2024-03-12 13:32:50 +02:00
Koitharu
e4e14214d9 Check if has stats on details screen 2024-03-11 12:49:20 +02:00
Koitharu
e40a39ca28 Merge branch 'devel' of github.com:KotatsuApp/Kotatsu into devel 2024-03-11 10:22:10 +02:00
Koitharu
82e711d619 Merge branch 'master' into devel 2024-03-11 10:21:23 +02:00
Koitharu
8c2bff78f7 Fix chapters duplication 2024-03-09 14:27:12 +02:00
Koitharu
4f2c38d4ee Update parsers 2024-03-09 13:05:02 +02:00
Koitharu
3c54fe4217 Fix crash on text selection
(cherry picked from commit 65abfc3a49)
2024-03-09 12:34:52 +02:00
Koitharu
750bf11fdc Update app/src/main/res/color-v23/selector_overlay.xml
(cherry picked from commit ba88ca8234)
2024-03-09 12:34:47 +02:00
Kaorun
b2c5ec5082 Fixed hover color
(cherry picked from commit 63470db6f5)
2024-03-09 12:34:39 +02:00
Koitharu
f97d4d452f Fix null url crash
(cherry picked from commit 1e39ae48ec)
2024-03-09 12:34:33 +02:00
Koitharu
640fe272c8 Fix passing intent data to ViewModels
(cherry picked from commit 094b0f694c)
2024-03-09 12:34:21 +02:00
Koitharu
f730e80bb7 Use numeric keyboard if app password is numeric
(cherry picked from commit f98bb87d6e)
2024-03-09 12:33:53 +02:00
ines Djimli
d975e92991 Translated using Weblate (Arabic)
Currently translated at 88.8% (8 of 9 strings)

Co-authored-by: ines Djimli <djimliines124@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ar/
Translation: Kotatsu/plurals
2024-03-09 12:23:22 +02:00
Deivinni Silva
0d29190bd1 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (616 of 616 strings)

Co-authored-by: Deivinni Silva <deivinnimds3656@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-03-09 12:23:22 +02:00
Макар Разин
76c07b1567 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (616 of 616 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (616 of 616 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (616 of 616 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-03-09 12:23:22 +02:00
Hosted Weblate
55c82a6f5c 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-03-09 12:23:22 +02:00
Nayuki
f81d298315 Translated using Weblate (Thai)
Currently translated at 68.8% (414 of 601 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2024-03-09 12:23:22 +02:00
maryush
fd17e1ea20 Translated using Weblate (Polish)
Currently translated at 100.0% (616 of 616 strings)

Translated using Weblate (Polish)

Currently translated at 99.0% (610 of 616 strings)

Translated using Weblate (Polish)

Currently translated at 91.5% (550 of 601 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Polish)

Currently translated at 88.3% (531 of 601 strings)

Co-authored-by: Mariusz <maryush@gmail.com>
Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-03-09 12:23:22 +02:00
Infy's Tagalog Translations
a1dc401eee Translated using Weblate (Filipino)
Currently translated at 100.0% (616 of 616 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (601 of 601 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-03-09 12:23:22 +02:00
Anon
b5ee465cde Translated using Weblate (Serbian)
Currently translated at 99.5% (613 of 616 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-03-09 12:23:22 +02:00
Scrambled777
956b04e974 Translated using Weblate (Hindi)
Currently translated at 100.0% (616 of 616 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-03-09 12:23:22 +02:00
abc0922001
f65c213e2d Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: abc0922001 <abc0922001@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2024-03-09 12:23:22 +02:00
gekka
813ce2e195 Translated using Weblate (Chinese (Simplified))
Currently translated at 99.3% (612 of 616 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.8% (615 of 616 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.8% (615 of 616 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (601 of 601 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-03-09 12:23:22 +02:00
Madaraki
0eb320ec76 Translated using Weblate (Russian)
Currently translated at 99.6% (614 of 616 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (601 of 601 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: Madaraki <115705267+Madaraki-chan@users.noreply.github.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-03-09 12:23:22 +02:00
Oğuz Ersen
b17aa6c031 Translated using Weblate (Turkish)
Currently translated at 100.0% (616 of 616 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-03-09 12:23:22 +02:00
a
97b5102e6c Translated using Weblate (Portuguese)
Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: a <cooki3yt2004@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-03-09 12:23:22 +02:00
INeido
fe408c0832 Translated using Weblate (Hungarian)
Currently translated at 100.0% (601 of 601 strings)

Translated using Weblate (German)

Currently translated at 99.1% (596 of 601 strings)

Translated using Weblate (Hungarian)

Currently translated at 51.9% (312 of 601 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Hungarian)

Currently translated at 29.1% (175 of 601 strings)

Translated using Weblate (German)

Currently translated at 97.0% (583 of 601 strings)

Co-authored-by: INeido <reg@neido.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/hu/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hu/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-03-09 12:23:22 +02:00
gallegonovato
4f3d1a9814 Translated using Weblate (Spanish)
Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-03-09 12:23:22 +02:00
Koitharu
65abfc3a49 Fix crash on text selection 2024-03-06 16:18:40 +02:00
Koitharu
ba88ca8234 Update app/src/main/res/color-v23/selector_overlay.xml 2024-03-06 16:16:47 +02:00
Kaorun
63470db6f5 Fixed hover color 2024-03-06 16:16:47 +02:00
Koitharu
1e39ae48ec Fix null url crash 2024-03-04 20:03:52 +02:00
Koitharu
6fcc45d554 Merge branch 'devel' of github.com:KotatsuApp/Kotatsu into devel 2024-03-04 19:59:45 +02:00
Koitharu
094b0f694c Fix passing intent data to ViewModels 2024-03-04 19:58:46 +02:00
Koitharu
5d1a2fcf77 Statistics filters 2024-03-04 16:31:39 +02:00
Koitharu
876675445d Stats chart for single manga 2024-03-04 14:42:31 +02:00
ztimms73
51362e6cce Remove LICENSE appendix that should be removed initially 2024-03-02 15:07:01 +03:00
Koitharu
f7a70680bd Timeline stats per manga 2024-03-01 15:00:38 +02:00
Koitharu
8e82db441c Empty stats state 2024-03-01 10:34:31 +02:00
Koitharu
f2626c668d Switch and click preference 2024-02-29 16:15:44 +02:00
Koitharu
4694215ccc Statistics periods 2024-02-29 15:28:57 +02:00
Koitharu
096f5b15dc Clearing stats 2024-02-29 14:27:52 +02:00
Koitharu
101d357eff Stats activity 2024-02-29 14:01:31 +02:00
Koitharu
11cd5609bb Use stats for reading time estimation 2024-02-29 12:12:09 +02:00
Koitharu
fda59996aa Improve stats ui 2024-02-29 12:01:09 +02:00
Koitharu
20461112d2 Merge branch 'devel' into feature/stats 2024-02-29 11:20:31 +02:00
Koitharu
f98bb87d6e Use numeric keyboard if app password is numeric 2024-02-29 11:20:10 +02:00
Koitharu
c451952a1e Merge branch 'devel' into feature/stats 2024-02-29 10:00:49 +02:00
Koitharu
f8cbc9692f Fix local manga directories chapters 2024-02-28 16:12:49 +02:00
Koitharu
9f3113363b Merge remote-tracking branch 'weblate/devel' into devel 2024-02-28 14:40:02 +02:00
Koitharu
dba36838d4 Download format preference 2024-02-28 14:28:59 +02:00
Koitharu
f6de1b02d7 Fix download item ui 2024-02-28 14:06:08 +02:00
abc0922001
d6b8e2fd9e Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: abc0922001 <abc0922001@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
a
5227240478 Translated using Weblate (Portuguese)
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: a <cooki3yt2004@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Infy's Tagalog Translations
8f65ea6535 Translated using Weblate (Filipino)
Currently translated at 99.8% (596 of 597 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-02-28 12:59:46 +01:00
Kyoya
7d7a6eadd2 Translated using Weblate (Turkish)
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: Kyoya <thelol9181@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Scrambled777
40f1ad3181 Translated using Weblate (Hindi)
Currently translated at 100.0% (597 of 597 strings)

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

Translated using Weblate (Serbian)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/sr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-28 12:59:46 +01:00
gallegonovato
a84cf97982 Translated using Weblate (Spanish)
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Lokmane Abdelhakim Djilani
3a8eb58fd1 Translated using Weblate (Arabic)
Currently translated at 58.1% (347 of 597 strings)

Co-authored-by: Lokmane Abdelhakim Djilani <lokdabdo@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
gekka
5d75e9af4a Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (597 of 597 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (597 of 597 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (597 of 597 strings)

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

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Çınar
c0a2f0b533 Translated using Weblate (Turkish)
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: Çınar <cinardogan110@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Макар Разин
40867dd2b6 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (597 of 597 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (597 of 597 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (597 of 597 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-02-28 12:59:46 +01:00
Koitharu
c3294e6459 Fix double pages mode enabling 2024-02-28 13:58:23 +02:00
Koitharu
5139feb51a Fix pages saving 2024-02-28 13:55:02 +02:00
Koitharu
6b1240fccb Fix crashes 2024-02-24 14:26:31 +02:00
Koitharu
e00a5b7505 Fix open Kitsu auth #773 2024-02-24 13:50:48 +02:00
Koitharu
2c07d2c8e1 Increase Kitsu password max length #774 2024-02-24 13:17:03 +02:00
Koitharu
45c3c05f01 Fix updating history in incognito mode #783 2024-02-24 13:13:35 +02:00
Koitharu
e97a745713 Fix filter ui issue #779 2024-02-24 12:50:59 +02:00
Koitharu
2dc4de0a3c Update dependencies 2024-02-24 12:25:44 +02:00
Koitharu
3cf2c58058 Local manga info dialog 2024-02-24 12:16:26 +02:00
Scrambled777
1e19f32fc5 Translated using Weblate (Hindi)
Currently translated at 22.6% (135 of 596 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Naga
99e4359523 Translated using Weblate (English)
Currently translated at 100.0% (596 of 596 strings)

Co-authored-by: Naga <yz2000.pro@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Alex Georgiou
04868488cc Translated using Weblate (English)
Currently translated at 100.0% (596 of 596 strings)

Co-authored-by: Alex Georgiou <alexandrosgeorgiou35@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Naga
2b3b406b84 Translated using Weblate (French)
Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (French)

Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (French)

Currently translated at 99.4% (593 of 596 strings)

Co-authored-by: Naga <yz2000.pro@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-24 12:14:04 +02:00
Infy's Tagalog Translations
7ab3c75232 Translated using Weblate (Filipino)
Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fil/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-24 12:14:04 +02:00
Макар Разин
61f7755465 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (596 of 596 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-02-24 12:14:04 +02:00
GpixeL
9389015ab9 Translated using Weblate (Indonesian)
Currently translated at 97.6% (582 of 596 strings)

Co-authored-by: GpixeL <gamesfire313@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Сергій
bc56a94aa6 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (596 of 596 strings)

Co-authored-by: Сергій <sergiy.goncharuk.1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Madaraki
7cfcaec6dd Translated using Weblate (Russian)
Currently translated at 100.0% (596 of 596 strings)

Co-authored-by: Madaraki <115705267+Madaraki-chan@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
LaFouine-38
39c7ae31cd Translated using Weblate (French)
Currently translated at 92.6% (552 of 596 strings)

Co-authored-by: LaFouine-38 <thomasjb0208@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Sergio Varela
9349eccc0c Translated using Weblate (Spanish)
Currently translated at 100.0% (596 of 596 strings)

Co-authored-by: Sergio Varela <sergitroll9@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
gekka
8204934359 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.8% (595 of 596 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.4% (592 of 595 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Oğuz Ersen
b5497c571e Translated using Weblate (Turkish)
Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (595 of 595 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Koitharu
35a2ac4b04 Simple reading stats display 2024-02-21 09:49:47 +02:00
Koitharu
b4d52f1367 Fix track worker notification behavior 2024-02-20 12:46:12 +02:00
jsericksk
81d4a3cf68 Fix grid view in ChaptersSheet 2024-02-19 17:32:02 -03:00
jsericksk
c2e30b3009 Update changes 2024-02-18 23:20:43 -03:00
jsericksk
0c823f1056 Add feature to toggle between list and grid view for chapters 2024-02-18 23:19:35 -03:00
jsericksk
44adbde536 Create chapterGridItemAD and its associated item_chapter_grid 2024-02-18 23:19:35 -03:00
jsericksk
ae0b405ba5 Add grid view option to chapters menu 2024-02-18 23:19:27 -03:00
Zakhar Timoshenko
325a8be484 Fix wrong sources count if NSFW sources are disabled 2024-02-18 23:56:19 +03:00
Koitharu
f39ccb6223 Stats settings 2024-02-18 13:38:46 +02:00
Koitharu
6cb6c891dd Collecting reading stats 2024-02-18 13:11:41 +02:00
Koitharu
8cc04b0f7a Add Remove from history action on details activity 2024-02-18 10:07:42 +02:00
Koitharu
258dbf3dc3 Fix reader info bar text size 2024-02-18 09:26:57 +02:00
Koitharu
e7af4e8450 Fix backup restore dialog layout #770 2024-02-18 09:20:34 +02:00
Koitharu
0c25c61858 Fix filtering pages by branches 2024-02-18 09:14:49 +02:00
Koitharu
abc3e45907 Fix reader state on changed 2024-02-18 09:08:22 +02:00
Koitharu
bd98d8eded Fix onboarding sources selection 2024-02-18 08:44:13 +02:00
Koitharu
2e81f41073 Update dependencies 2024-02-18 08:44:13 +02:00
Oğuz Ersen
5cccebc416 Translated using Weblate (Turkish)
Currently translated at 100.0% (591 of 591 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-02-18 08:43:59 +02:00
Макар Разин
c668ffd555 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (591 of 591 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-02-18 08:43:59 +02:00
gekka
a0f77b715f Translated using Weblate (Chinese (Simplified))
Currently translated at 99.6% (589 of 591 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.8% (590 of 591 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-02-18 08:43:59 +02:00
Anonymous
2831843a25 Translated using Weblate (Chinese (Simplified))
Currently translated at 99.8% (590 of 591 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-02-18 08:43:59 +02:00
Koitharu
86c1aa11b0 Fix crash 2024-02-17 12:45:02 +02:00
Koitharu
d71514ec7a Configurable dir for silent pages saving 2024-02-17 12:31:12 +02:00
Koitharu
92ed320f57 Add compact navbar option 2024-02-17 10:41:55 +02:00
Koitharu
2de1fe8b77 Fix icon tint 2024-02-16 12:29:52 +02:00
vianh
cebc3cd9e8 Adjust search screen ui 2024-02-16 10:40:23 +02:00
vianh
6c0e2e2b90 Fix mirror domain switching 2024-02-16 10:40:23 +02:00
vianh
b4bd923ce8 Improve webtoon zoom fling 2024-02-16 10:39:17 +02:00
Koitharu
813561fd3b Merge remote-tracking branch 'weblate/devel' into devel 2024-02-16 10:37:43 +02:00
Jordan \"Tes\" Michel
4107336132 Translated using Weblate (French)
Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (French)

Currently translated at 93.4% (552 of 591 strings)

Co-authored-by: Jordan \"Tes\" Michel <jordan.michel13@yahoo.fr>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-16 10:35:52 +02:00
Anon
30d9d87c17 Translated using Weblate (Serbian)
Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Serbian)

Currently translated at 99.6% (589 of 591 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-02-16 10:35:52 +02:00
gekka
c4b5be657d Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (591 of 591 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-02-16 10:35:51 +02:00
Koitharu
8a763b2b9f Bring back adaptive reader control preference 2024-02-16 10:35:27 +02:00
Koitharu
c783378022 Update app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt 2024-02-15 10:26:22 +02:00
Isira Seneviratne
c4355f16e8 Improve serializable extra extension 2024-02-15 10:26:22 +02:00
Isira Seneviratne
522dfc2418 Use SoftwareKeyboardControllerCompat 2024-02-15 10:22:28 +02:00
Koitharu
06d03e3ddd Fix SyncSettings lifecycle 2024-02-14 12:49:46 +02:00
Koitharu
9dc8c7959d Update parsers 2024-02-14 12:19:08 +02:00
Davi Silveira
db219020ca Translated using Weblate (Portuguese)
Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (591 of 591 strings)

Co-authored-by: Davi Silveira <davilego10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-14 12:00:37 +02:00
Aiman Sara
c04edcb76c Translated using Weblate (Malay)
Currently translated at 53.9% (319 of 591 strings)

Translated using Weblate (Malay)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Aiman Sara <aimansara21@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ms/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ms/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-14 12:00:37 +02:00
Anon
936fc2e4ae Translated using Weblate (Serbian)
Currently translated at 99.6% (589 of 591 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-02-14 12:00:37 +02:00
gekka
cbed866665 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (591 of 591 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-02-14 12:00:37 +02:00
Madaraki
ac568b6361 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (591 of 591 strings)

Co-authored-by: Madaraki <115705267+Madaraki-chan@users.noreply.github.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-02-14 12:00:37 +02:00
Oğuz Ersen
84157f988d Translated using Weblate (Turkish)
Currently translated at 100.0% (591 of 591 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-02-14 12:00:37 +02:00
gallegonovato
6f6339f0f8 Translated using Weblate (Spanish)
Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (589 of 589 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-02-14 12:00:37 +02:00
Jordan \"Tes\" Michel
a7019b9096 Translated using Weblate (French)
Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (French)

Currently translated at 93.4% (552 of 591 strings)

Co-authored-by: Jordan \"Tes\" Michel <jordan.michel13@yahoo.fr>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-14 10:33:12 +01:00
Davi Silveira
867e3f10ca Translated using Weblate (Portuguese)
Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (591 of 591 strings)

Co-authored-by: Davi Silveira <davilego10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-14 10:33:12 +01:00
Aiman Sara
fb2cf04d75 Translated using Weblate (Malay)
Currently translated at 53.9% (319 of 591 strings)

Translated using Weblate (Malay)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Aiman Sara <aimansara21@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ms/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ms/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-14 10:33:12 +01:00
Anon
3ed44ba0d6 Translated using Weblate (Serbian)
Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Serbian)

Currently translated at 99.6% (589 of 591 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-02-14 10:33:12 +01:00
gekka
b78104a0f1 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (591 of 591 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-02-14 10:33:12 +01:00
Madaraki
e4ee93f77c Translated using Weblate (Ukrainian)
Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (591 of 591 strings)

Co-authored-by: Madaraki <115705267+Madaraki-chan@users.noreply.github.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-02-14 10:33:11 +01:00
Oğuz Ersen
c6e8da5f23 Translated using Weblate (Turkish)
Currently translated at 100.0% (591 of 591 strings)

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

Translated using Weblate (Spanish)

Currently translated at 100.0% (589 of 589 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-02-14 10:33:11 +01:00
Koitharu
bec2195971 Fix processing tap actions in reader when not resumed 2024-02-13 08:03:09 +02:00
Zakhar Timoshenko
722ac4ecc7 Tweak chapter item 2024-02-12 19:02:23 +03:00
Koitharu
516c1c02a6 Improve chapters list ui accessibility #752 2024-02-12 14:26:20 +02:00
Koitharu
0cb7e71781 Fix chapters selection decoration 2024-02-12 09:07:32 +02:00
Koitharu
36a74f32df Show hint if navigation section is unavailable #751 2024-02-10 16:52:49 +02:00
Koitharu
0e4ef32642 Show battery percentage in reader bar 2024-02-10 16:33:18 +02:00
Koitharu
3125cac4c8 Use constant textSize for reader info bar #748 2024-02-10 15:16:08 +02:00
Koitharu
5d9016d1bc Do not query suggestions by blacklisted tags #749 2024-02-10 14:59:25 +02:00
Koitharu
c5eeb89d10 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (589 of 589 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (589 of 589 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-02-10 14:43:16 +02:00
Davi Silveira
4f8f43cab1 Translated using Weblate (Portuguese)
Currently translated at 100.0% (586 of 586 strings)

Co-authored-by: Davi Silveira <davilego10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-02-10 14:43:16 +02:00
Diegofmar
4cbff308ce Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (586 of 586 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (586 of 586 strings)

Co-authored-by: Diegofmar <diego2771@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-02-10 14:43:16 +02:00
Макар Разин
d786ab7deb Translated using Weblate (Polish)
Currently translated at 83.7% (491 of 586 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (586 of 586 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (586 of 586 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (586 of 586 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2024-02-10 14:43:16 +02:00
Mirkó Attila
c823d402ff Translated using Weblate (Hungarian)
Currently translated at 11.2% (66 of 586 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (9 of 9 strings)

Added translation using Weblate (Hungarian)

Co-authored-by: Mirkó Attila <kisatti007hun@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/hu/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hu/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-10 14:43:16 +02:00
gekka
12e68db41f Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (586 of 586 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (586 of 586 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (586 of 586 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-02-10 14:43:16 +02:00
Madaraki
96717321d2 Translated using Weblate (Ukrainian)
Currently translated at 99.8% (585 of 586 strings)

Co-authored-by: Madaraki <115705267+Madaraki-chan@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2024-02-10 14:43:16 +02:00
Oğuz Ersen
044b5590ef Translated using Weblate (Turkish)
Currently translated at 100.0% (586 of 586 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-02-10 14:43:16 +02:00
Eduardo Malaspina
00112ebb44 Translated using Weblate (Spanish)
Currently translated at 100.0% (586 of 586 strings)

Co-authored-by: Eduardo Malaspina <vaio0@swismail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-02-10 14:43:16 +02:00
Koitharu
00dde80fdf Update parsers 2024-02-10 14:42:12 +02:00
Koitharu
f1dfc4ebd6 Fix strings 2024-02-10 14:33:57 +02:00
Koitharu
5426edd83a Option to disable reading time estimator 2024-02-10 14:10:38 +02:00
Koitharu
2a500eb2cb Improve background works notifications 2024-02-10 13:54:36 +02:00
Koitharu
2310ed06c1 Translated using Weblate (Russian)
Currently translated at 99.8% (585 of 586 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-02-07 14:01:25 +02:00
gallegonovato
68ed7a09d6 Translated using Weblate (Spanish)
Currently translated at 100.0% (584 of 584 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (583 of 583 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-02-07 14:01:25 +02:00
Scrambled777
6cdb56e740 Translated using Weblate (Hindi)
Currently translated at 22.9% (134 of 583 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/hi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-07 14:01:25 +02:00
大王叫我来巡山
35fb78c924 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (583 of 583 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-02-07 14:01:25 +02:00
Davi Silveira
8c3b5d7f53 Translated using Weblate (Portuguese)
Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Portuguese)

Currently translated at 99.8% (582 of 583 strings)

Co-authored-by: Davi Silveira <davilego10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-07 14:01:25 +02:00
Макар Разин
af6592a8df Translated using Weblate (Belarusian)
Currently translated at 99.8% (582 of 583 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
2024-02-07 14:01:25 +02:00
Deivinni Silva
9efb82d887 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (583 of 583 strings)

Translated using Weblate (English)

Currently translated at 100.0% (583 of 583 strings)

Co-authored-by: Deivinni Silva <deivinnimds3656@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-02-07 14:01:25 +02:00
gekka
f8722ddc73 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (583 of 583 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (583 of 583 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-02-07 14:01:25 +02:00
Nayuki
656ac97153 Translated using Weblate (Thai)
Currently translated at 69.8% (407 of 583 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2024-02-07 14:01:25 +02:00
LL Magical
4fc23f8f54 Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.8% (582 of 583 strings)

Translated using Weblate (Portuguese)

Currently translated at 99.8% (582 of 583 strings)

Co-authored-by: LL Magical <lolayami2004@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-02-07 14:01:25 +02:00
Joshua “Josh”
e1f325993f Translated using Weblate (German)
Currently translated at 100.0% (583 of 583 strings)

Co-authored-by: Joshua “Josh” <22joshua.mueller22@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2024-02-07 14:01:25 +02:00
Koitharu
4c3938a1fd Reader fullscreen option 2024-02-07 13:53:45 +02:00
Koitharu
530dfa8cde Fix workers scheduling 2024-02-07 13:19:51 +02:00
Koitharu
58d1c3de26 Default webtoon zoom out option 2024-02-07 12:55:51 +02:00
Koitharu
ba2ed6a2ef Webtoon reader improvements 2024-02-07 10:25:23 +02:00
Koitharu
2d909854fb Update parsers 2024-02-07 09:40:30 +02:00
Koitharu
cba694bedd Merge branch 'master' into devel 2024-02-07 09:08:09 +02:00
Koitharu
e5cf1be91a Fix build signature validation 2024-02-05 12:54:14 +02:00
Koitharu
72a1dd8227 Increase versionCode 2024-02-03 16:58:24 +02:00
Koitharu
8558b00dca Fix chapter number for saved chapters 2024-02-03 16:53:26 +02:00
Koitharu
8e9175d5f0 Update parsers 2024-02-03 16:52:15 +02:00
Koitharu
eae40d9b90 Double reader fixes 2024-02-03 16:52:15 +02:00
Koitharu
2d61209696 Double page reader for reversed mode 2024-02-03 16:52:15 +02:00
Koitharu
d24754f2a0 Handle MAL errors in html 2024-02-03 16:52:15 +02:00
Koitharu
54ef02ad88 Fix downloading 2024-02-03 16:52:14 +02:00
gekka
e2a82920b6 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (583 of 583 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-02-03 16:37:45 +02:00
ALi.w
d494030d50 Translated using Weblate (Persian)
Currently translated at 44.5% (260 of 583 strings)

Co-authored-by: ALi.w <aminnimaj@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
2024-02-03 16:37:45 +02:00
Anon
73369f9a6d Translated using Weblate (Serbian)
Currently translated at 99.6% (581 of 583 strings)

Translated using Weblate (English)

Currently translated at 99.8% (582 of 583 strings)

Translated using Weblate (Serbian)

Currently translated at 99.3% (579 of 583 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/sr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-03 16:37:45 +02:00
Madaraki
cc1da6e8da Translated using Weblate (Ukrainian)
Currently translated at 100.0% (583 of 583 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (583 of 583 strings)

Co-authored-by: Madaraki <115705267+Madaraki-chan@users.noreply.github.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-02-03 16:37:45 +02:00
Oğuz Ersen
668a5bd040 Translated using Weblate (Turkish)
Currently translated at 100.0% (583 of 583 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-02-03 16:37:45 +02:00
Sup Kelelawar
413605b520 Translated using Weblate (Indonesian)
Currently translated at 100.0% (583 of 583 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (583 of 583 strings)

Co-authored-by: Sup Kelelawar <apkfile007@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-01-31 16:37:37 +02:00
Anna
bdf23a0d62 Translated using Weblate (German)
Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Anna <scheffler.anna@gmx.net>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/de/
Translation: Kotatsu/plurals
2024-01-31 16:37:37 +02:00
Eji-san
4c5d26d4b4 Translated using Weblate (Indonesian)
Currently translated at 100.0% (581 of 581 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Eji-san <ejierubani@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/id/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-01-31 16:37:37 +02:00
Wrld-Lain
3b7ad7f28d Translated using Weblate (Spanish)
Currently translated at 99.8% (578 of 579 strings)

Co-authored-by: Wrld-Lain <anzonx@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-01-31 16:37:37 +02:00
gekka
331af45a29 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (583 of 583 strings)

Added translation using Weblate (Chinese (Literary))

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (581 of 581 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (579 of 579 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (579 of 579 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-01-31 16:37:37 +02:00
Макар Разин
d349bd30c9 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (579 of 579 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (579 of 579 strings)

Co-authored-by: Макар Разин <makarrazin14@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-01-31 16:37:37 +02:00
ZerOriSama
3349e3abc5 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (579 of 579 strings)

Co-authored-by: ZerOriSama <godarms2010@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-01-31 16:37:37 +02:00
gekka
4aa31ead67 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (579 of 579 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-01-31 16:37:37 +02:00
Clxff H3r4ld0
113da3b6c1 Translated using Weblate (Indonesian)
Currently translated at 100.0% (566 of 566 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Clxff H3r4ld0 <123844876+clxf12@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/id/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-01-31 16:37:37 +02:00
何意挽秋風
8b027e2f45 Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (565 of 565 strings)

Added translation using Weblate (Chinese (Traditional))

Co-authored-by: 何意挽秋風 <9120518@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-01-31 16:37:37 +02:00
FateXBlood
9c462b1a3a Translated using Weblate (Nepali)
Currently translated at 41.5% (235 of 565 strings)

Translated using Weblate (Nepali)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: FateXBlood <zecrofelix@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ne/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ne/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-01-31 16:37:37 +02:00
Madaraki
a5bc8c1e9e Translated using Weblate (Ukrainian)
Currently translated at 98.9% (573 of 579 strings)

Translated using Weblate (Russian)

Currently translated at 99.6% (577 of 579 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (566 of 566 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (566 of 566 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (565 of 565 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (565 of 565 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (564 of 564 strings)

Co-authored-by: Madaraki <115705267+Madaraki-chan@users.noreply.github.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-01-31 16:37:37 +02:00
Deivinni Silva
ebb77c68cc Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (564 of 564 strings)

Co-authored-by: Deivinni Silva <deivinnimds3656@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-01-31 16:37:37 +02:00
Madaraki
74ddf86ebe Translated using Weblate (Russian)
Currently translated at 100.0% (563 of 563 strings)

Co-authored-by: Madaraki <Madaraki@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-01-31 16:37:37 +02:00
nobSEIFO
12d2fdaf3e Translated using Weblate (Arabic)
Currently translated at 59.6% (335 of 562 strings)

Co-authored-by: nobSEIFO <BEZAI2002@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-01-31 16:37:37 +02:00
D
8cfc97c795 Translated using Weblate (Italian)
Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (562 of 562 strings)

Co-authored-by: D <dogger56@hotmail.it>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/it/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-01-31 16:37:37 +02:00
Сергій
3855ca802e Translated using Weblate (Ukrainian)
Currently translated at 100.0% (563 of 563 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (560 of 560 strings)

Co-authored-by: Сергій <sergiy.goncharuk.1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/uk/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-01-31 16:37:37 +02:00
Макар Разин
9db427275f Translated using Weblate (Belarusian)
Currently translated at 100.0% (579 of 579 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.2% (556 of 560 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (560 of 560 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ru/
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
Translation: Kotatsu/plurals
2024-01-31 16:37:37 +02:00
yearn
3a38644089 Added translation using Weblate (Chinese (Literary))
Co-authored-by: yearn <13676164923@163.com>
2024-01-31 16:37:37 +02:00
Anibal Dams
60a34ec092 Translated using Weblate (Spanish)
Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Anibal Dams <elnini120@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/es/
Translation: Kotatsu/plurals
2024-01-31 16:37:37 +02:00
shehuaizong
acd79f12e3 Added translation using Weblate (Franco-Provençal)
Co-authored-by: shehuaizong <1511985899@qq.com>
2024-01-31 16:37:37 +02:00
Infy's Tagalog Translations
461d7ed578 Translated using Weblate (Filipino)
Currently translated at 100.0% (558 of 558 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-01-31 16:37:37 +02:00
Alex Georgiou
5374ac390c Translated using Weblate (Greek)
Currently translated at 100.0% (557 of 557 strings)

Co-authored-by: Alex Georgiou <alexandrosgeorgiou35@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/el/
Translation: Kotatsu/Strings
2024-01-31 16:37:37 +02:00
plum7x
913a67a652 Translated using Weblate (Chinese (Traditional))
Currently translated at 94.0% (524 of 557 strings)

Co-authored-by: plum7x <plumgift@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2024-01-31 16:37:37 +02:00
大王叫我来巡山
e7a920e43a Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (565 of 565 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (563 of 563 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (562 of 562 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (560 of 560 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (558 of 558 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (557 of 557 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-01-31 16:37:37 +02:00
scaledzdn
9668b3ef5f Translated using Weblate (Indonesian)
Currently translated at 99.6% (555 of 557 strings)

Co-authored-by: scaledzdn <zaidanmovic0512@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-01-31 16:37:37 +02:00
Ridhoardhiansyah7
9581f937de Translated using Weblate (Indonesian)
Currently translated at 99.6% (555 of 557 strings)

Co-authored-by: Ridhoardhiansyah7 <Zxx97607@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-01-31 16:37:37 +02:00
Platiplus
44ef6f6dbf Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.4% (554 of 557 strings)

Co-authored-by: Platiplus <quazarweb@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-01-31 16:37:37 +02:00
Oğuz Ersen
af11697133 Translated using Weblate (Turkish)
Currently translated at 100.0% (581 of 581 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (579 of 579 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (566 of 566 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (565 of 565 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (563 of 563 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (562 of 562 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (557 of 557 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/tr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-01-31 16:37:37 +02:00
gallegonovato
09ff356790 Translated using Weblate (Spanish)
Currently translated at 100.0% (566 of 566 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (565 of 565 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (563 of 563 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (560 of 560 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (558 of 558 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (557 of 557 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-01-31 16:37:37 +02:00
ALi.w
92ea50d6b6 Translated using Weblate (Persian)
Currently translated at 41.9% (235 of 560 strings)

Translated using Weblate (Persian)

Currently translated at 41.9% (234 of 558 strings)

Translated using Weblate (Persian)

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Persian)

Currently translated at 40.0% (223 of 557 strings)

Added translation using Weblate (Persian)

Co-authored-by: ALi.w <aminnimaj@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fa/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-01-31 16:37:37 +02:00
Koitharu
077107e9a7 Merge branch 'master' into devel 2024-01-31 16:30:30 +02:00
Koitharu
edca0e5334 Partially migrate to new collections 2024-01-31 15:55:33 +02:00
Koitharu
a4e2675d61 Update dependencies 2024-01-31 15:23:52 +02:00
Koitharu
892f95a7a6 Fix scrobbling chapter index 2024-01-31 13:56:48 +02:00
Koitharu
95aaa967a8 Scrobbling using Kitsu #360 2024-01-31 13:29:18 +02:00
Koitharu
5687ca6e96 Kitsu auth implementation 2024-01-30 15:41:03 +02:00
Koitharu
d0ee185d2e Merge branch 'feature/kitsu' of github.com:KotatsuApp/Kotatsu into devel 2024-01-30 13:59:34 +02:00
Koitharu
21a3ac0902 Use crop parameter for wsrv.nl #721 2024-01-30 13:33:59 +02:00
Koitharu
1382ab7933 Improve reader actions editor 2024-01-30 09:43:52 +02:00
Koitharu
aabdd281f3 Fix imports 2024-01-30 09:14:25 +02:00
Koitharu
131a0ffcaa Double reader integration 2024-01-29 18:57:18 +02:00
Koitharu
4194609929 Editor actions fixes 2024-01-29 11:54:21 +02:00
Zakhar Timoshenko
889b799d8d TLS 1.3 support for Android < 10 2024-01-28 17:58:38 +03:00
Koitharu
6f7f3dc5e2 Configurable reader tap actions 2024-01-27 18:06:51 +02:00
Koitharu
72187e7da0 Double reader integration 2024-01-27 12:25:39 +02:00
Koitharu
f881cc439a Draft double reader implementation 2024-01-27 12:25:34 +02:00
Zakhar Timoshenko
ccdebf6789 Initial support of long press in reader 2024-01-27 13:19:22 +03:00
Koitharu
4db61d3c04 Merge branch 'master' into devel 2024-01-24 12:32:04 +02:00
Koitharu
e7c9d1943d Disable autofill for protect password fields #702 2024-01-24 11:56:04 +02:00
Koitharu
b1240e7efa Last read order in favorites #705 2024-01-24 11:53:53 +02:00
Koitharu
a0a72b1192 Fix webtoon scroll dispatching 2024-01-24 11:36:48 +02:00
Koitharu
5d9a59d577 Update parsers and migrate to float chapter numbers 2024-01-22 17:11:00 +02:00
Koitharu
83cb35fe6e Fix pages numbers 2024-01-22 13:13:14 +02:00
Koitharu
0fff53ae47 Readers refactor 2024-01-22 13:00:56 +02:00
Koitharu
a95017a5f0 Update reader mode icons 2024-01-22 12:02:25 +02:00
Koitharu
9251823d9a Vertical reader mode 2024-01-21 11:37:22 +02:00
Koitharu
ce8f87272b Refactor ReadActivity menus 2024-01-21 11:02:53 +02:00
Koitharu
db1ddf539c Incognito mode indicator in reader 2024-01-21 10:10:20 +02:00
Koitharu
d56fc674ab Fix favorites counters 2024-01-20 15:54:15 +02:00
Isira Seneviratne
c9fcc0f0f8 Improve signature check 2024-01-20 09:23:33 +02:00
Koitharu
da2ad40adf Update parsers 2024-01-20 09:19:29 +02:00
Koitharu
af5716a8ce Fix import dialog injection 2024-01-20 09:16:06 +02:00
Koitharu
a98202e15e Update supported links domains 2024-01-20 09:08:26 +02:00
Koitharu
d6887e2d75 Skip unsupported sources in global search 2024-01-20 09:07:27 +02:00
Koitharu
ba6afd44dd Fix reading time format 2024-01-19 16:37:25 +02:00
Koitharu
0b55c4d037 Add volume headers to chapters list 2024-01-19 16:36:36 +02:00
Koitharu
2a5300a634 Show all favorites on categories screen 2024-01-18 16:16:12 +02:00
Koitharu
59bfa929fd Current branch indicator 2024-01-18 14:54:30 +02:00
Koitharu
c5d88f8700 Refactor reading time estimator 2024-01-18 13:38:25 +02:00
Koitharu
a1120ea709 Fix pages list scrolling 2024-01-18 12:19:34 +02:00
Koitharu
796af6b811 Refactor chapters description 2024-01-18 10:58:11 +02:00
Zakhar Timoshenko
eafd878413 Approximate reading time preview 2024-01-17 22:22:37 +03:00
Zakhar Timoshenko
9baf2bfcd9 Update Material lib 2024-01-17 22:20:13 +03:00
Zakhar Timoshenko
0b4dd5beef Enhance chapter items 2024-01-17 16:09:55 +03:00
Zakhar Timoshenko
fd01367601 Move Kitsu scrobbler to kotlin dir 2023-06-05 17:33:12 +03:00
Zakhar Timoshenko
cb64740349 Merge branch 'devel' into feature/kitsu
# Conflicts:
#	app/src/main/res/values/strings.xml
2023-06-05 17:21:56 +03:00
Zakhar Timoshenko
6fdcaf0d02 Merge branch 'devel' into feature/kitsu
# Conflicts:
#	app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt
#	app/src/main/res/values/strings.xml
2023-05-29 17:29:05 +03:00
Koitharu
56de725cf1 Open Kitsu auth activity 2023-05-06 18:15:24 +03:00
Koitharu
7a2ad47405 Merge branch 'devel' into feature/kitsu 2023-05-06 18:07:02 +03:00
Zakhar Timoshenko
41551451b0 Part 1 2023-05-06 16:15:17 +03:00
Zakhar Timoshenko
d5c24cd5c8 Initial adding of Kitsu scrobbler 2023-05-03 13:54:19 +03:00
461 changed files with 15144 additions and 4179 deletions

3
.idea/gradle.xml generated
View File

@@ -4,9 +4,8 @@
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
<option name="testRunner" value="GRADLE" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-17" /> <option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />

53
LICENSE
View File

@@ -619,56 +619,3 @@ Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee. copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@@ -19,7 +19,7 @@ Kotatsu is a free and open source manga reader for Android.
* Tablet-optimized Material You UI * Tablet-optimized Material You UI
* Standard and Webtoon-optimized reader * Standard and Webtoon-optimized reader
* Notifications about new chapters with updates feed * Notifications about new chapters with updates feed
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList * Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
* Password/fingerprint protect access to the app * Password/fingerprint protect access to the app
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices * History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 618 versionCode = 633
versionName = '6.6.8' versionName = '6.8.3'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {
@@ -82,18 +82,19 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:57c9d26916') { implementation('com.github.KotatsuApp:kotatsu-parsers:44ea9fe709') {
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.22' implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.23'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.activity:activity-ktx:1.8.2' implementation 'androidx.activity:activity-ktx:1.8.2'
implementation 'androidx.fragment:fragment-ktx:1.6.2' implementation 'androidx.fragment:fragment-ktx:1.6.2'
implementation 'androidx.collection:collection-ktx:1.4.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-service:2.7.0' implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
implementation 'androidx.lifecycle:lifecycle-process:2.7.0' implementation 'androidx.lifecycle:lifecycle-process:2.7.0'
@@ -103,8 +104,9 @@ dependencies {
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.11.0' implementation 'com.google.android.material:material:1.12.0-beta01'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.7.0' implementation 'androidx.lifecycle:lifecycle-common-java8:2.7.0'
implementation 'androidx.webkit:webkit:1.10.0'
implementation 'androidx.work:work-runtime:2.9.0' implementation 'androidx.work:work-runtime:2.9.0'
//noinspection GradleDependency //noinspection GradleDependency
@@ -120,18 +122,18 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
implementation 'com.squareup.okio:okio:3.7.0' implementation 'com.squareup.okio:okio:3.9.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'com.google.dagger:hilt-android:2.50' implementation 'com.google.dagger:hilt-android:2.51.1'
kapt 'com.google.dagger:hilt-compiler:2.50' kapt 'com.google.dagger:hilt-compiler:2.51.1'
implementation 'androidx.hilt:hilt-work:1.1.0' implementation 'androidx.hilt:hilt-work:1.2.0'
kapt 'androidx.hilt:hilt-compiler:1.1.0' kapt 'androidx.hilt:hilt-compiler:1.2.0'
implementation 'io.coil-kt:coil-base:2.5.0' implementation 'io.coil-kt:coil-base:2.6.0'
implementation 'io.coil-kt:coil-svg:2.5.0' implementation 'io.coil-kt:coil-svg:2.6.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2' implementation 'io.noties.markwon:core:4.6.2'
@@ -146,19 +148,19 @@ dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.13' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.13'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20231013' testImplementation 'org.json:json:20240303'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0'
androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0' androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test:core-ktx:1.5.0' androidTestImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5' androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0'
androidTestImplementation 'androidx.room:room-testing:2.6.1' androidTestImplementation 'androidx.room:room-testing:2.6.1'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0' androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.50' androidTestImplementation 'com.google.dagger:hilt-android-testing:2.51.1'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.50' kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.51.1'
} }

View File

@@ -57,6 +57,7 @@ class AppShortcutManagerTest {
page = 4, page = 4,
scroll = 2, scroll = 2,
percent = 0.3f, percent = 0.3f,
force = false,
) )
awaitUpdate() awaitUpdate()

View File

@@ -7,7 +7,9 @@ import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Assert.* import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -61,6 +63,7 @@ class AppBackupAgentTest {
page = 3, page = 3,
scroll = 40, scroll = 40,
percent = 0.2f, percent = 0.2f,
force = false,
) )
val history = checkNotNull(historyRepository.getOne(SampleData.manga)) val history = checkNotNull(historyRepository.getOne(SampleData.manga))

View File

@@ -10,6 +10,8 @@ class CurlLoggingInterceptor(
private val curlOptions: String? = null private val curlOptions: String? = null
) : Interceptor { ) : Interceptor {
private val escapeRegex = Regex("([\\[\\]\"])")
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request() val request = chain.request()
var isCompressed = false var isCompressed = false
@@ -40,7 +42,7 @@ class CurlLoggingInterceptor(
if (isCompressed) { if (isCompressed) {
curlCmd.append(" --compressed") curlCmd.append(" --compressed")
} }
curlCmd.append(" \"").append(request.url).append('"') curlCmd.append(" \"").append(request.url.toString().escape()).append('"')
log("---cURL (" + request.url + ")") log("---cURL (" + request.url + ")")
log(curlCmd.toString()) log(curlCmd.toString())
@@ -48,7 +50,12 @@ class CurlLoggingInterceptor(
return chain.proceed(request) return chain.proceed(request)
} }
private fun String.escape() = replace("\"", "\\\"") private fun String.escape() = replace(escapeRegex) { match ->
"\\" + match.value
}
// .replace("\"", "\\\"")
// .replace("[", "\\[")
// .replace("]", "\\]")
private fun log(msg: String) { private fun log(msg: String) {
Log.d("CURL", msg) Log.d("CURL", msg)

View File

@@ -94,6 +94,34 @@
<data android:host="kotatsu.app" /> <data android:host="kotatsu.app" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name="org.koitharu.kotatsu.details.ui.DetailsActivity2"
android:exported="true">
<intent-filter>
<action android:name="${applicationId}.action.VIEW_MANGA" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="kotatsu.app" />
<data android:path="/manga" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kotatsu" />
<data android:host="manga" />
<data android:host="kotatsu.app" />
</intent-filter>
</activity>
<activity <activity
android:name="org.koitharu.kotatsu.reader.ui.ReaderActivity" android:name="org.koitharu.kotatsu.reader.ui.ReaderActivity"
android:exported="true"> android:exported="true">
@@ -145,6 +173,9 @@
<data android:host="sync-settings" /> <data android:host="sync-settings" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name="org.koitharu.kotatsu.settings.reader.ReaderTapGridConfigActivity"
android:label="@string/reader_actions" />
<activity <activity
android:name="org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity" android:name="org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity"
android:label="@string/local_manga_directories" /> android:label="@string/local_manga_directories" />
@@ -218,12 +249,30 @@
<data android:host="shikimori-auth" /> <data android:host="shikimori-auth" />
<data android:host="anilist-auth" /> <data android:host="anilist-auth" />
<data android:host="mal-auth" /> <data android:host="mal-auth" />
<data android:host="kitsu-auth" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity <activity
android:name="org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity" android:name="org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity"
android:label="@string/sources_catalog" /> android:label="@string/sources_catalog" />
<activity
android:name="org.koitharu.kotatsu.scrobbling.kitsu.ui.KitsuAuthActivity"
android:exported="false"
android:label="@string/kitsu"
tools:ignore="AppLinkUrlError">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="kotatsu+kitsu" />
</intent-filter>
</activity>
<activity
android:name="org.koitharu.kotatsu.stats.ui.StatsActivity"
android:label="@string/reading_stats" />
<activity
android:name="org.koitharu.kotatsu.alternatives.ui.AlternativesActivity"
android:label="@string/alternatives" />
<service <service
android:name="androidx.work.impl.foreground.SystemForegroundService" android:name="androidx.work.impl.foreground.SystemForegroundService"

View File

@@ -0,0 +1,85 @@
package org.koitharu.kotatsu.alternatives.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.almostEquals
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
private const val MAX_PARALLELISM = 4
private const val MATCH_THRESHOLD = 0.2f
class AlternativesUseCase @Inject constructor(
private val sourcesRepository: MangaSourcesRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
suspend operator fun invoke(manga: Manga): Flow<Manga> {
val sources = getSources(manga.source)
if (sources.isEmpty()) {
return emptyFlow()
}
val semaphore = Semaphore(MAX_PARALLELISM)
return channelFlow {
for (source in sources) {
val repository = mangaRepositoryFactory.create(source)
if (!repository.isSearchSupported) {
continue
}
launch {
val list = runCatchingCancellable {
semaphore.withPermit {
repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
}
}.getOrDefault(emptyList())
for (item in list) {
if (item.matches(manga)) {
send(item)
}
}
}
}
}.map {
runCatchingCancellable {
mangaRepositoryFactory.create(it.source).getDetails(it)
}.getOrDefault(it)
}
}
private suspend fun getSources(ref: MangaSource): List<MangaSource> {
val result = ArrayList<MangaSource>(MangaSource.entries.size - 2)
result.addAll(sourcesRepository.getEnabledSources())
result.sortByDescending { it.priority(ref) }
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
return result
}
private fun Manga.matches(ref: Manga): Boolean {
return matchesTitles(title, ref.title) ||
matchesTitles(title, ref.altTitle) ||
matchesTitles(altTitle, ref.title) ||
matchesTitles(altTitle, ref.altTitle)
}
private fun matchesTitles(a: String?, b: String?): Boolean {
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, MATCH_THRESHOLD)
}
private fun MangaSource.priority(ref: MangaSource): Int {
var res = 0
if (locale == ref.locale) res += 2
if (contentType == ref.contentType) res++
return res
}
}

View File

@@ -0,0 +1,129 @@
package org.koitharu.kotatsu.alternatives.domain
import androidx.room.withTransaction
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.history.data.toMangaHistory
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
class MigrateUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val mangaDataRepository: MangaDataRepository,
private val database: MangaDatabase,
private val progressUpdateUseCase: ProgressUpdateUseCase,
) {
suspend operator fun invoke(oldManga: Manga, newManga: Manga) {
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
runCatchingCancellable {
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
}.getOrDefault(oldManga)
} else {
oldManga
}
val newDetails = if (newManga.chapters.isNullOrEmpty()) {
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
} else {
newManga
}
mangaDataRepository.storeManga(newDetails)
database.withTransaction {
// replace favorites
val favoritesDao = database.getFavouritesDao()
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
if (oldFavourites.isNotEmpty()) {
favoritesDao.delete(oldManga.id)
for (f in oldFavourites) {
val e = f.copy(
mangaId = newManga.id,
)
favoritesDao.upsert(e)
}
}
// replace history
val historyDao = database.getHistoryDao()
val oldHistory = historyDao.find(oldDetails.id)
if (oldHistory != null) {
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
historyDao.delete(oldDetails.id)
historyDao.upsert(newHistory)
}
}
progressUpdateUseCase(newManga)
}
private fun makeNewHistory(
oldManga: Manga,
newManga: Manga,
history: HistoryEntity,
): HistoryEntity {
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
val branch = newManga.getPreferredBranch(null)
val chapters = checkNotNull(newManga.getChapters(branch))
val currentChapter = if (history.percent in 0f..1f) {
chapters[(chapters.lastIndex * history.percent).toInt()]
} else {
chapters.first()
}
return HistoryEntity(
mangaId = newManga.id,
createdAt = history.createdAt,
updatedAt = System.currentTimeMillis(),
chapterId = currentChapter.id,
page = history.page,
scroll = history.scroll,
percent = history.percent,
deletedAt = 0,
chaptersCount = chapters.size,
)
}
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
val oldChapters = checkNotNull(oldManga.getChapters(branch))
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
if (index < 0) {
index = if (history.percent in 0f..1f) {
(oldChapters.lastIndex * history.percent).toInt()
} else {
0
}
}
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
val newBranch = if (newChapters.containsKey(branch)) {
branch
} else {
newManga.getPreferredBranch(null)
}
val newChapterId = checkNotNull(newChapters[newBranch]).let {
val oldChapter = oldChapters[index]
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
}.id
return HistoryEntity(
mangaId = newManga.id,
createdAt = history.createdAt,
updatedAt = System.currentTimeMillis(),
chapterId = newChapterId,
page = history.page,
scroll = history.scroll,
percent = PROGRESS_NONE,
deletedAt = 0,
chaptersCount = checkNotNull(newChapters[newBranch]).size,
)
}
private fun List<MangaChapter>.findByNumber(volume: Int, number: Float): MangaChapter? {
return if (number <= 0f) {
null
} else {
firstOrNull { it.volume == volume && it.number == number }
}
}
}

View File

@@ -0,0 +1,92 @@
package org.koitharu.kotatsu.alternatives.ui
import android.text.style.ForegroundColorSpan
import androidx.core.content.ContextCompat
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import coil.request.ImageRequest
import coil.transform.CircleCropTransformation
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
import kotlin.math.sign
import com.google.android.material.R as materialR
fun alternativeAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
listener: OnListItemClickListener<MangaAlternativeModel>,
) = adapterDelegateViewBinding<MangaAlternativeModel, ListModel, ItemMangaAlternativeBinding>(
{ inflater, parent -> ItemMangaAlternativeBinding.inflate(inflater, parent, false) },
) {
val colorGreen = ContextCompat.getColor(context, R.color.common_green)
val colorRed = ContextCompat.getColor(context, R.color.common_red)
val clickListener = AdapterDelegateClickListenerAdapter(this, listener)
itemView.setOnClickListener(clickListener)
binding.buttonMigrate.setOnClickListener(clickListener)
binding.chipSource.setOnClickListener(clickListener)
bind { payloads ->
binding.textViewTitle.text = item.manga.title
binding.textViewSubtitle.text = buildSpannedString {
if (item.chaptersCount > 0) {
append(context.resources.getQuantityString(R.plurals.chapters, item.chaptersCount, item.chaptersCount))
} else {
append(context.getString(R.string.no_chapters))
}
when (item.chaptersDiff.sign) {
-1 -> inSpans(ForegroundColorSpan(colorRed)) {
append("")
append(item.chaptersDiff.toString())
}
1 -> inSpans(ForegroundColorSpan(colorGreen)) {
append(" ▲ +")
append(item.chaptersDiff.toString())
}
}
}
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
binding.chipSource.also { chip ->
chip.text = item.manga.source.title
ImageRequest.Builder(context)
.data(item.manga.source.faviconUri())
.lifecycle(lifecycleOwner)
.crossfade(false)
.size(context.resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
.target(ChipIconTarget(chip))
.placeholder(R.drawable.ic_web)
.fallback(R.drawable.ic_web)
.error(R.drawable.ic_web)
.source(item.manga.source)
.transformations(CircleCropTransformation())
.allowRgb565(true)
.enqueueWith(coil)
}
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
size(CoverSizeResolver(binding.imageViewCover))
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
transformations(TrimTransformation())
allowRgb565(true)
tag(item.manga)
source(item.manga.source)
enqueueWith(coil)
}
}
}

View File

@@ -0,0 +1,114 @@
package org.koitharu.kotatsu.alternatives.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import coil.ImageLoader
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.search.ui.SearchActivity
import javax.inject.Inject
@AndroidEntryPoint
class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
OnListItemClickListener<MangaAlternativeModel> {
@Inject
lateinit var coil: ImageLoader
private val viewModel by viewModels<AlternativesViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityAlternativesBinding.inflate(layoutInflater))
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
subtitle = viewModel.manga.title
}
val listAdapter = BaseListAdapter<ListModel>()
.addDelegate(ListItemType.MANGA_LIST_DETAILED, alternativeAD(coil, this, this))
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, null))
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
with(viewBinding.recyclerView) {
setHasFixedSize(true)
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
adapter = listAdapter
}
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
viewModel.content.observe(this, listAdapter)
viewModel.onMigrated.observeEvent(this) {
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
startActivity(DetailsActivity.newIntent(this, it))
finishAfterTransition()
}
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
)
viewBinding.recyclerView.updatePadding(
bottom = insets.bottom + viewBinding.recyclerView.paddingTop,
)
}
override fun onItemClick(item: MangaAlternativeModel, view: View) {
when (view.id) {
R.id.chip_source -> startActivity(SearchActivity.newIntent(this, item.manga.source, viewModel.manga.title))
R.id.button_migrate -> confirmMigration(item.manga)
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
}
}
private fun confirmMigration(target: Manga) {
MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED)
.setIcon(R.drawable.ic_replace)
.setTitle(R.string.manga_migration)
.setMessage(
getString(
R.string.migrate_confirmation,
viewModel.manga.title,
viewModel.manga.source.title,
target.title,
target.source.title,
),
)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.migrate) { _, _ ->
viewModel.migrate(target)
}.show()
}
companion object {
fun newIntent(context: Context, manga: Manga) = Intent(context, AlternativesActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
}
}

View File

@@ -0,0 +1,98 @@
package org.koitharu.kotatsu.alternatives.ui
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEmpty
import kotlinx.coroutines.flow.runningFold
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
@HiltViewModel
class AlternativesViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val alternativesUseCase: AlternativesUseCase,
private val migrateUseCase: MigrateUseCase,
private val extraProvider: ListExtraProvider,
) : BaseViewModel() {
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
val onMigrated = MutableEventFlow<Manga>()
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
private var migrationJob: Job? = null
init {
launchJob(Dispatchers.Default) {
val ref = runCatchingCancellable {
mangaRepositoryFactory.create(manga.source).getDetails(manga)
}.getOrDefault(manga)
val refCount = ref.chaptersCount()
alternativesUseCase(ref)
.map {
MangaAlternativeModel(
manga = it,
progress = extraProvider.getProgress(it.id),
referenceChapters = refCount,
)
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
acc.filterIsInstance<MangaAlternativeModel>() + item + LoadingFooter()
}.onEmpty {
emit(
listOf(
EmptyState(
icon = R.drawable.ic_empty_common,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary,
actionStringRes = 0,
),
),
)
}.collect {
content.value = it
}
content.value = content.value.filterNot { it is LoadingFooter }
}
}
fun migrate(target: Manga) {
if (migrationJob?.isActive == true) {
return
}
migrationJob = launchLoadingJob(Dispatchers.Default) {
migrateUseCase(manga, target)
onMigrated.call(target)
}
}
private suspend fun mapList(list: List<Manga>, refCount: Int): List<MangaAlternativeModel> {
return list.map {
MangaAlternativeModel(
manga = it,
progress = extraProvider.getProgress(it.id),
referenceChapters = refCount,
)
}
}
}

View File

@@ -0,0 +1,21 @@
package org.koitharu.kotatsu.alternatives.ui
import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
data class MangaAlternativeModel(
val manga: Manga,
val progress: Float,
private val referenceChapters: Int,
) : ListModel {
val chaptersCount = manga.chaptersCount()
val chaptersDiff: Int
get() = if (referenceChapters == 0 || chaptersCount == 0) 0 else chaptersCount - referenceChapters
override fun areItemsTheSame(other: ListModel): Boolean {
return other is MangaAlternativeModel && other.manga.id == manga.id
}
}

View File

@@ -25,6 +25,7 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
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.databinding.FragmentListSimpleBinding import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
@@ -71,7 +72,7 @@ class BookmarksFragment :
) { ) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
selectionController = ListSelectionController( selectionController = ListSelectionController(
activity = requireActivity(), appCompatDelegate = checkNotNull(findAppCompatDelegate()),
decoration = BookmarksSelectionDecoration(binding.root.context), decoration = BookmarksSelectionDecoration(binding.root.context),
registryOwner = this, registryOwner = this,
callback = this, callback = this,
@@ -100,7 +101,7 @@ class BookmarksFragment :
} }
viewModel.onError.observeEvent( viewModel.onError.observeEvent(
viewLifecycleOwner, viewLifecycleOwner,
SnackbarErrorObserver(binding.recyclerView, this) SnackbarErrorObserver(binding.recyclerView, this),
) )
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
} }
@@ -206,10 +207,11 @@ class BookmarksFragment :
companion object { companion object {
@Deprecated( @Deprecated(
"", ReplaceWith( "",
ReplaceWith(
"BookmarksFragment()", "BookmarksFragment()",
"org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment" "org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment",
) ),
) )
fun newInstance() = BookmarksFragment() fun newInstance() = BookmarksFragment()
} }

View File

@@ -13,7 +13,6 @@ import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
class BookmarksAdapter( class BookmarksAdapter(
@@ -32,13 +31,6 @@ class BookmarksAdapter(
} }
override fun getSectionText(context: Context, position: Int): CharSequence? { override fun getSectionText(context: Context, position: Int): CharSequence? {
val list = items return findHeader(position)?.getText(context)
for (i in (0..position).reversed()) {
val item = list.getOrNull(i) ?: continue
if (item is ListHeader) {
return item.getText(context)
}
}
return null
} }
} }

View File

@@ -37,6 +37,7 @@ import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Deprecated("")
@AndroidEntryPoint @AndroidEntryPoint
class BookmarksSheet : class BookmarksSheet :
BaseAdaptiveSheet<SheetPagesBinding>(), BaseAdaptiveSheet<SheetPagesBinding>(),

View File

@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.parsers.util.SuspendLazy
import javax.inject.Inject import javax.inject.Inject
@Deprecated("")
@HiltViewModel @HiltViewModel
class BookmarksSheetViewModel @Inject constructor( class BookmarksSheetViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.browser package org.koitharu.kotatsu.browser
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -12,31 +11,42 @@ import android.webkit.CookieManager
import androidx.core.graphics.Insets 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 org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability 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.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.network.UserAgents import org.koitharu.kotatsu.parsers.model.MangaSource
import javax.inject.Inject
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@SuppressLint("SetJavaScriptEnabled") @AndroidEntryPoint
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback { class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
private lateinit var onBackPressedCallback: WebViewBackPressedCallback private lateinit var onBackPressedCallback: WebViewBackPressedCallback
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) { if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
return return
} }
supportActionBar?.run { supportActionBar?.run {
setDisplayHomeAsUpEnabled(true) setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
} }
with(viewBinding.webView.settings) { val userAgent = intent?.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)?.let { source ->
javaScriptEnabled = true val repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository
userAgentString = UserAgents.CHROME_MOBILE repository?.headers?.get(CommonHeaders.USER_AGENT)
} }
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)
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar) viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
@@ -57,16 +67,6 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
} }
} }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
viewBinding.webView.saveState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
viewBinding.webView.restoreState(savedInstanceState)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
super.onCreateOptionsMenu(menu) super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.opt_browser, menu) menuInflater.inflate(R.menu.opt_browser, menu)
@@ -81,11 +81,14 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
} }
R.id.action_browser -> { R.id.action_browser -> {
val intent = Intent(Intent.ACTION_VIEW) val url = viewBinding.webView.url?.toUriOrNull()
intent.data = Uri.parse(viewBinding.webView.url) if (url != null) {
try { val intent = Intent(Intent.ACTION_VIEW)
startActivity(Intent.createChooser(intent, item.title)) intent.data = url
} catch (_: ActivityNotFoundException) { try {
startActivity(Intent.createChooser(intent, item.title))
} catch (_: ActivityNotFoundException) {
}
} }
true true
} }
@@ -136,11 +139,13 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
companion object { companion object {
private const val EXTRA_TITLE = "title" private const val EXTRA_TITLE = "title"
private const val EXTRA_SOURCE = "source"
fun newIntent(context: Context, url: String, title: String?): Intent { fun newIntent(context: Context, url: String, source: MangaSource?, title: String?): Intent {
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)
} }
} }
} }

View File

@@ -20,7 +20,7 @@ class CaptchaNotifier(
) : EventListener { ) : EventListener {
fun notify(exception: CloudFlareProtectedException) { fun notify(exception: CloudFlareProtectedException) {
if (!context.checkNotificationPermission()) { if (!context.checkNotificationPermission(CHANNEL_ID)) {
return return
} }
val manager = NotificationManagerCompat.from(context) val manager = NotificationManagerCompat.from(context)

View File

@@ -27,9 +27,8 @@ import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.TaggedActivityResult import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.network.UserAgents
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@@ -41,17 +40,12 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
@Inject @Inject
lateinit var cookieJar: MutableCookieJar lateinit var cookieJar: MutableCookieJar
private lateinit var cfClient: CloudFlareClient
private var onBackPressedCallback: WebViewBackPressedCallback? = null private var onBackPressedCallback: WebViewBackPressedCallback? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (!catchingWebViewUnavailability { if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
setContentView(
ActivityBrowserBinding.inflate(
layoutInflater,
),
)
}) {
return return
} }
supportActionBar?.run { supportActionBar?.run {
@@ -59,13 +53,9 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
} }
val url = intent?.dataString.orEmpty() val url = intent?.dataString.orEmpty()
with(viewBinding.webView.settings) { cfClient = CloudFlareClient(cookieJar, this, url)
javaScriptEnabled = true viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
domStorageEnabled = true viewBinding.webView.webViewClient = cfClient
databaseEnabled = true
userAgentString = intent?.getStringExtra(ARG_UA) ?: UserAgents.CHROME_MOBILE
}
viewBinding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also { onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
onBackPressedDispatcher.addCallback(it) onBackPressedDispatcher.addCallback(it)
} }
@@ -91,16 +81,6 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
super.onDestroy() super.onDestroy()
} }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
viewBinding.webView.saveState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
viewBinding.webView.restoreState(savedInstanceState)
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean { override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.opt_captcha, menu) menuInflater.inflate(R.menu.opt_captcha, menu)
return super.onCreateOptionsMenu(menu) return super.onCreateOptionsMenu(menu)
@@ -125,15 +105,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
} }
R.id.action_retry -> { R.id.action_retry -> {
lifecycleScope.launch { restartCheck()
viewBinding.webView.stopLoading()
yield()
val targetUrl = intent?.dataString?.toHttpUrlOrNull()
if (targetUrl != null) {
clearCfCookies(targetUrl)
viewBinding.webView.loadUrl(targetUrl.toString())
}
}
true true
} }
@@ -159,6 +131,10 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
viewBinding.progressBar.isInvisible = true viewBinding.progressBar.isInvisible = true
} }
override fun onLoopDetected() {
restartCheck()
}
override fun onCheckPassed() { override fun onCheckPassed() {
pendingResult = RESULT_OK pendingResult = RESULT_OK
finishAfterTransition() finishAfterTransition()
@@ -178,10 +154,23 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
} }
private fun restartCheck() {
lifecycleScope.launch {
viewBinding.webView.stopLoading()
yield()
cfClient.reset()
val targetUrl = intent?.dataString?.toHttpUrlOrNull()
if (targetUrl != null) {
clearCfCookies(targetUrl)
viewBinding.webView.loadUrl(targetUrl.toString())
}
}
}
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) { private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
cookieJar.removeCookies(url) { cookie -> cookieJar.removeCookies(url) { cookie ->
val name = cookie.name val name = cookie.name
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf") name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf") || name == "csrftoken"
} }
} }

View File

@@ -11,4 +11,6 @@ interface CloudFlareCallback : BrowserCallback {
fun onPageLoaded() fun onPageLoaded()
fun onCheckPassed() fun onCheckPassed()
fun onLoopDetected()
} }

View File

@@ -7,6 +7,7 @@ import org.koitharu.kotatsu.browser.BrowserClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
private const val CF_CLEARANCE = "cf_clearance" private const val CF_CLEARANCE = "cf_clearance"
private const val LOOP_COUNTER = 3
class CloudFlareClient( class CloudFlareClient(
private val cookieJar: MutableCookieJar, private val cookieJar: MutableCookieJar,
@@ -15,6 +16,7 @@ class CloudFlareClient(
) : BrowserClient(callback) { ) : BrowserClient(callback) {
private val oldClearance = getClearance() private val oldClearance = getClearance()
private var counter = 0
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon) super.onPageStarted(view, url, favicon)
@@ -31,10 +33,20 @@ class CloudFlareClient(
callback.onPageLoaded() callback.onPageLoaded()
} }
fun reset() {
counter = 0
}
private fun checkClearance() { private fun checkClearance() {
val clearance = getClearance() val clearance = getClearance()
if (clearance != null && clearance != oldClearance) { if (clearance != null && clearance != oldClearance) {
callback.onCheckPassed() callback.onCheckPassed()
} else {
counter++
if (counter >= LOOP_COUNTER) {
reset()
callback.onLoopDetected()
}
} }
} }

View File

@@ -55,7 +55,7 @@ open class BaseApp : Application(), Configuration.Provider {
lateinit var appValidator: AppValidator lateinit var appValidator: AppValidator
@Inject @Inject
lateinit var workScheduleManager: Provider<WorkScheduleManager> lateinit var workScheduleManager: WorkScheduleManager
@Inject @Inject
lateinit var workManagerProvider: Provider<WorkManager> lateinit var workManagerProvider: Provider<WorkManager>
@@ -83,7 +83,7 @@ open class BaseApp : Application(), Configuration.Provider {
processLifecycleScope.launch(Dispatchers.Default) { processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers() setupDatabaseObservers()
} }
workScheduleManager.get().init() workScheduleManager.init()
WorkServiceStopHelper(workManagerProvider).setup() WorkServiceStopHelper(workManagerProvider).setup()
} }

View File

@@ -6,12 +6,14 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okio.Closeable import okio.Closeable
import org.json.JSONArray import org.json.JSONArray
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import java.io.File import java.io.File
import java.util.EnumSet import java.util.EnumSet
import java.util.zip.ZipException
import java.util.zip.ZipFile import java.util.zip.ZipFile
class BackupZipInput(val file: File) : Closeable { class BackupZipInput private constructor(val file: File) : Closeable {
private val zipFile = ZipFile(file) private val zipFile = ZipFile(file)
@@ -41,4 +43,17 @@ class BackupZipInput(val file: File) : Closeable {
} }
} }
} }
companion object {
fun from(file: File): BackupZipInput = try {
val res = BackupZipInput(file)
if (res.zipFile.getEntry("index") == null) {
throw BadBackupFormatException(null)
}
res
} catch (e: ZipException) {
throw BadBackupFormatException(e)
}
}
} }

View File

@@ -54,6 +54,7 @@ class JsonDeserializer(private val json: JSONObject) {
page = json.getInt("page"), page = json.getInt("page"),
scroll = json.getDouble("scroll").toFloat(), scroll = json.getDouble("scroll").toFloat(),
percent = json.getFloatOrDefault("percent", -1f), percent = json.getFloatOrDefault("percent", -1f),
chaptersCount = json.getIntOrDefault("chapters", -1),
deletedAt = 0L, deletedAt = 0L,
) )

View File

@@ -41,6 +41,7 @@ class JsonSerializer private constructor(private val json: JSONObject) {
put("page", e.page) put("page", e.page)
put("scroll", e.scroll) put("scroll", e.scroll)
put("percent", e.percent) put("percent", e.percent)
put("chapters", e.chaptersCount)
}, },
) )

View File

@@ -20,6 +20,8 @@ interface ContentCache {
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>)
fun clear(source: MangaSource)
data class Key( data class Key(
val source: MangaSource, val source: MangaSource,
val url: String, val url: String,

View File

@@ -7,10 +7,12 @@ class ExpiringLruCache<T>(
val maxSize: Int, val maxSize: Int,
private val lifetime: Long, private val lifetime: Long,
private val timeUnit: TimeUnit, private val timeUnit: TimeUnit,
) { ) : Iterable<ContentCache.Key> {
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize) private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
override fun iterator(): Iterator<ContentCache.Key> = cache.snapshot().keys.iterator()
operator fun get(key: ContentCache.Key): T? { operator fun get(key: ContentCache.Key): T? {
val value = cache[key] ?: return null val value = cache[key] ?: return null
if (value.isExpired) { if (value.isExpired) {
@@ -30,4 +32,8 @@ class ExpiringLruCache<T>(
fun trimToSize(size: Int) { fun trimToSize(size: Int) {
cache.trimToSize(size) cache.trimToSize(size)
} }
fun remove(key: ContentCache.Key) {
cache.remove(key)
}
} }

View File

@@ -44,6 +44,12 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
relatedMangaCache[ContentCache.Key(source, url)] = related relatedMangaCache[ContentCache.Key(source, url)] = related
} }
override fun clear(source: MangaSource) {
clearCache(detailsCache, source)
clearCache(pagesCache, source)
clearCache(relatedMangaCache, source)
}
override fun onConfigurationChanged(newConfig: Configuration) = Unit override fun onConfigurationChanged(newConfig: Configuration) = Unit
override fun onLowMemory() = Unit override fun onLowMemory() = Unit
@@ -67,4 +73,12 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
else -> cache.trimToSize(cache.maxSize / 2) else -> cache.trimToSize(cache.maxSize / 2)
} }
} }
private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) {
cache.forEach { key ->
if (key.source == source) {
cache.remove(key)
}
}
}
} }

View File

@@ -19,4 +19,6 @@ class StubContentCache : ContentCache {
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? = null override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? = null
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) = Unit override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) = Unit
override fun clear(source: MangaSource) = Unit
} }

View File

@@ -30,6 +30,8 @@ import org.koitharu.kotatsu.core.db.migrations.Migration14To15
import org.koitharu.kotatsu.core.db.migrations.Migration15To16 import org.koitharu.kotatsu.core.db.migrations.Migration15To16
import org.koitharu.kotatsu.core.db.migrations.Migration16To17 import org.koitharu.kotatsu.core.db.migrations.Migration16To17
import org.koitharu.kotatsu.core.db.migrations.Migration17To18 import org.koitharu.kotatsu.core.db.migrations.Migration17To18
import org.koitharu.kotatsu.core.db.migrations.Migration18To19
import org.koitharu.kotatsu.core.db.migrations.Migration19To20
import org.koitharu.kotatsu.core.db.migrations.Migration1To2 import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration2To3 import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4 import org.koitharu.kotatsu.core.db.migrations.Migration3To4
@@ -48,20 +50,22 @@ import org.koitharu.kotatsu.history.data.HistoryDao
import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
import org.koitharu.kotatsu.stats.data.StatsDao
import org.koitharu.kotatsu.stats.data.StatsEntity
import org.koitharu.kotatsu.suggestions.data.SuggestionDao import org.koitharu.kotatsu.suggestions.data.SuggestionDao
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.tracker.data.TrackEntity 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 = 18 const val DATABASE_VERSION = 20
@Database( @Database(
entities = [ entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
ScrobblingEntity::class, MangaSourceEntity::class, ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class,
], ],
version = DATABASE_VERSION, version = DATABASE_VERSION,
) )
@@ -90,6 +94,8 @@ abstract class MangaDatabase : RoomDatabase() {
abstract fun getScrobblingDao(): ScrobblingDao abstract fun getScrobblingDao(): ScrobblingDao
abstract fun getSourcesDao(): MangaSourcesDao abstract fun getSourcesDao(): MangaSourcesDao
abstract fun getStatsDao(): StatsDao
} }
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf( fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
@@ -110,6 +116,8 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration15To16(), Migration15To16(),
Migration16To17(context), Migration16To17(context),
Migration17To18(), Migration17To18(),
Migration18To19(),
Migration19To20(),
) )
fun MangaDatabase(context: Context): MangaDatabase = Room fun MangaDatabase(context: Context): MangaDatabase = Room

View File

@@ -29,6 +29,9 @@ abstract class MangaSourcesDao {
@Query("SELECT * FROM sources ORDER BY sort_key") @Query("SELECT * FROM sources ORDER BY sort_key")
abstract fun observeAll(): Flow<List<MangaSourceEntity>> abstract fun observeAll(): Flow<List<MangaSourceEntity>>
@Query("SELECT enabled FROM sources WHERE source = :source")
abstract fun observeIsEnabled(source: String): Flow<Boolean>
@Query("SELECT IFNULL(MAX(sort_key),0) FROM sources") @Query("SELECT IFNULL(MAX(sort_key),0) FROM sources")
abstract suspend fun getMaxSortKey(): Int abstract suspend fun getMaxSortKey(): Int

View File

@@ -1,6 +1,10 @@
package org.koitharu.kotatsu.core.db.dao package org.koitharu.kotatsu.core.db.dao
import androidx.room.* import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
@@ -24,6 +28,9 @@ interface TrackLogsDao {
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)") @Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
suspend fun gc() suspend fun gc()
@Query("DELETE FROM track_logs WHERE id IN (SELECT id FROM track_logs ORDER BY created_at DESC LIMIT 0 OFFSET :size)")
suspend fun trim(size: Int)
@Query("SELECT COUNT(*) FROM track_logs") @Query("SELECT COUNT(*) FROM track_logs")
suspend fun count(): Int suspend fun count(): Int
} }

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration18To19 : Migration(18, 19) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE history ADD COLUMN `chapters` INTEGER NOT NULL DEFAULT -1")
db.execSQL("CREATE TABLE IF NOT EXISTS `stats` (`manga_id` INTEGER NOT NULL, `started_at` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `pages` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `started_at`), FOREIGN KEY(`manga_id`) REFERENCES `history`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
}
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration19To20 : Migration(19, 20) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE tracks_bk (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id))")
db.execSQL("INSERT INTO tracks_bk SELECT manga_id, chapters_total, last_chapter_id, chapters_new, last_check, last_notified_id FROM tracks")
db.execSQL("DROP TABLE tracks")
db.execSQL("CREATE TABLE tracks (`manga_id` INTEGER NOT NULL, `last_chapter_id` INTEGER NOT NULL, `chapters_new` INTEGER NOT NULL, `last_check_time` INTEGER NOT NULL, `last_chapter_date` INTEGER NOT NULL, `last_result` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("INSERT INTO tracks SELECT manga_id, last_chapter_id, chapters_new, last_check AS last_check_time, 0 AS last_chapter_date, 0 AS last_result FROM tracks_bk")
db.execSQL("DROP TABLE tracks_bk")
}
}

View File

@@ -0,0 +1,5 @@
package org.koitharu.kotatsu.core.exceptions
import java.io.IOException
class BadBackupFormatException(cause: Throwable?) : IOException(cause)

View File

@@ -0,0 +1,7 @@
package org.koitharu.kotatsu.core.exceptions
import okio.IOException
class NoDataReceivedException(
private val url: String,
) : IOException("No data has been received from $url")

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.core.exceptions
import org.koitharu.kotatsu.parsers.model.Manga
class UnsupportedSourceException(
message: String?,
val manga: Manga?,
) : IllegalArgumentException(message)

View File

@@ -21,7 +21,7 @@ abstract class ErrorObserver(
private val onResolved: Consumer<Boolean>?, private val onResolved: Consumer<Boolean>?,
) : FlowCollector<Throwable> { ) : FlowCollector<Throwable> {
protected val activity = host.context.findActivity() protected open val activity = host.context.findActivity()
private val lifecycleScope: LifecycleCoroutineScope private val lifecycleScope: LifecycleCoroutineScope
get() = checkNotNull(fragment?.viewLifecycleScope ?: (activity as? LifecycleOwner)?.lifecycle?.coroutineScope) get() = checkNotNull(fragment?.viewLifecycleScope ?: (activity as? LifecycleOwner)?.lifecycle?.coroutineScope)
@@ -36,7 +36,7 @@ abstract class ErrorObserver(
private fun isAlive(): Boolean { private fun isAlive(): Boolean {
return when { return when {
fragment != null -> fragment.view != null fragment != null -> fragment.view != null
activity != null -> !activity.isDestroyed activity != null -> activity?.isDestroyed == false
else -> true else -> true
} }
} }

View File

@@ -8,13 +8,16 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import okhttp3.Headers import okhttp3.Headers
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.TaggedActivityResult import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
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.settings.sources.auth.SourceAuthActivity import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import kotlin.coroutines.Continuation import kotlin.coroutines.Continuation
@@ -59,6 +62,11 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
false false
} }
is UnsupportedSourceException -> {
e.manga?.let { openAlternatives(it) }
false
}
else -> false else -> false
} }
@@ -74,7 +82,12 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
private fun openInBrowser(url: String) { private fun openInBrowser(url: String) {
val context = activity ?: fragment?.activity ?: return val context = activity ?: fragment?.activity ?: return
context.startActivity(BrowserActivity.newIntent(context, url, null)) context.startActivity(BrowserActivity.newIntent(context, url, null, null))
}
private fun openAlternatives(manga: Manga) {
val context = activity ?: fragment?.activity ?: return
context.startActivity(AlternativesActivity.newIntent(context, manga))
} }
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager) private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
@@ -86,6 +99,7 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
is CloudFlareProtectedException -> R.string.captcha_solve is CloudFlareProtectedException -> R.string.captcha_solve
is AuthRequiredException -> R.string.sign_in is AuthRequiredException -> R.string.sign_in
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0 is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
else -> 0 else -> 0
} }

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.model
import android.net.Uri import android.net.Uri
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.collection.MutableObjectIntMap
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.iterator import org.koitharu.kotatsu.core.util.ext.iterator
@@ -31,12 +32,14 @@ fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) { if (size <= 1) {
return size return size
} }
val acc = HashMap<String?, Int>() val acc = MutableObjectIntMap<String?>()
for (item in this) { for (item in this) {
val branch = item.chapter.branch val branch = item.chapter.branch
acc[branch] = (acc[branch] ?: 0) + 1 acc[branch] = acc.getOrDefault(branch, 0) + 1
} }
return acc.values.max() var max = 0
acc.forEachValue { x -> if (x > max) max = x }
return max
} }
@get:StringRes @get:StringRes
@@ -128,3 +131,19 @@ fun MangaChapter.formatNumber(): String? {
} }
return chaptersNumberFormat.format(number.toDouble()) return chaptersNumberFormat.format(number.toDouble())
} }
fun Manga.chaptersCount(): Int {
if (chapters.isNullOrEmpty()) {
return 0
}
val counters = MutableObjectIntMap<String?>()
var max = 0
chapters?.forEach { x ->
val c = counters.getOrDefault(x.branch, 0) + 1
counters[x.branch] = c
if (max < c) {
max = c
}
}
return max
}

View File

@@ -7,11 +7,11 @@ import okhttp3.Interceptor
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
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.network.UserAgents
import org.koitharu.kotatsu.parsers.util.mergeWith import org.koitharu.kotatsu.parsers.util.mergeWith
import java.net.IDN import java.net.IDN
import javax.inject.Inject import javax.inject.Inject
@@ -20,6 +20,7 @@ import javax.inject.Singleton
@Singleton @Singleton
class CommonHeadersInterceptor @Inject constructor( class CommonHeadersInterceptor @Inject constructor(
private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>, private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>,
private val mangaLoaderContextLazy: Lazy<MangaLoaderContextImpl>,
) : Interceptor { ) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
@@ -38,7 +39,7 @@ class CommonHeadersInterceptor @Inject constructor(
headersBuilder.mergeWith(it, replaceExisting = false) headersBuilder.mergeWith(it, replaceExisting = false)
} }
if (headersBuilder[CommonHeaders.USER_AGENT] == null) { if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
headersBuilder[CommonHeaders.USER_AGENT] = UserAgents.CHROME_MOBILE headersBuilder[CommonHeaders.USER_AGENT] = mangaLoaderContextLazy.get().getDefaultUserAgent()
} }
if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) { if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) {
val idn = IDN.toASCII(repository.domain) val idn = IDN.toASCII(repository.domain)

View File

@@ -7,6 +7,7 @@ import coil.request.ErrorResult
import coil.request.ImageResult import coil.request.ImageResult
import coil.request.SuccessResult import coil.request.SuccessResult
import coil.size.Dimension import coil.size.Dimension
import coil.size.isOriginal
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -46,11 +47,13 @@ class ImageProxyInterceptor @Inject constructor(
.scheme("https") .scheme("https")
.host("wsrv.nl") .host("wsrv.nl")
.addQueryParameter("url", url.toString()) .addQueryParameter("url", url.toString())
.addQueryParameter("fit", "outside")
.addQueryParameter("we", null) .addQueryParameter("we", null)
val size = request.sizeResolver.size() val size = request.sizeResolver.size()
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) } if (!size.isOriginal) {
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) } newUrl.addQueryParameter("crop", "cover")
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
}
val newRequest = request.newBuilder() val newRequest = request.newBuilder()
.data(newUrl.build()) .data(newUrl.build())

View File

@@ -1,16 +1,9 @@
package org.koitharu.kotatsu.core.os package org.koitharu.kotatsu.core.os
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.core.content.pm.PackageInfoCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.security.MessageDigest
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -18,29 +11,13 @@ import javax.inject.Singleton
class AppValidator @Inject constructor( class AppValidator @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) { ) {
@Suppress("NewApi")
val isOriginalApp by lazy { val isOriginalApp by lazy {
getCertificateSHA1Fingerprint() == CERT_SHA1 val certificates = mapOf(CERT_SHA256.hexToByteArray() to PackageManager.CERT_INPUT_SHA256)
PackageInfoCompat.hasSignatures(context.packageManager, context.packageName, certificates, false)
} }
@Suppress("DEPRECATION")
@SuppressLint("PackageManagerGetSignatures")
private fun getCertificateSHA1Fingerprint(): String? = runCatching {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES)
val signatures = requireNotNull(packageInfo?.signatures)
val cert: ByteArray = signatures.first().toByteArray()
val input: InputStream = ByteArrayInputStream(cert)
val cf = CertificateFactory.getInstance("X509")
val c = cf.generateCertificate(input) as X509Certificate
val md: MessageDigest = MessageDigest.getInstance("SHA1")
val publicKey: ByteArray = md.digest(c.encoded)
return publicKey.byte2HexFormatted()
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrNull()
private companion object { private companion object {
private const val CERT_SHA256 = "67e15100bb809301783edcb6348fa3bbf83034d91e62868a91053dbd70db3f18"
private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE"
} }
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
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.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
@@ -18,24 +19,20 @@ import java.util.EnumSet
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) { class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("") get() = ConfigKey.Domain("localhost")
override val availableSortOrders: Set<SortOrder> override val availableSortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java) get() = EnumSet.allOf(SortOrder::class.java)
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga = stub(manga)
TODO("Not yet implemented")
}
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> { override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
TODO("Not yet implemented")
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
TODO("Not yet implemented")
}
override suspend fun getAvailableTags(): Set<MangaTag> { override suspend fun getAvailableTags(): Set<MangaTag> = stub(null)
TODO("Not yet implemented")
private fun stub(manga: Manga?): Nothing {
throw UnsupportedSourceException("Usage of Dummy parser", manga)
} }
} }

View File

@@ -4,18 +4,25 @@ import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.util.Base64 import android.util.Base64
import android.webkit.WebView import android.webkit.WebView
import androidx.annotation.MainThread
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
import org.koitharu.kotatsu.core.util.ext.toList import org.koitharu.kotatsu.core.util.ext.toList
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.UserAgents
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
@@ -32,12 +39,15 @@ class MangaLoaderContextImpl @Inject constructor(
private var webViewCached: WeakReference<WebView>? = null private var webViewCached: WeakReference<WebView>? = null
private val userAgentLazy = SuspendLazy {
withContext(Dispatchers.Main) {
obtainWebView().settings.userAgentString
}.sanitizeHeaderValue()
}
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main) { override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main) {
val webView = webViewCached?.get() ?: WebView(androidContext).also { val webView = obtainWebView()
it.settings.javaScriptEnabled = true
webViewCached = WeakReference(it)
}
suspendCoroutine { cont -> suspendCoroutine { cont ->
webView.evaluateJavascript(script) { result -> webView.evaluateJavascript(script) { result ->
cont.resume(result?.takeUnless { it == "null" }) cont.resume(result?.takeUnless { it == "null" })
@@ -45,6 +55,14 @@ class MangaLoaderContextImpl @Inject constructor(
} }
} }
override fun getDefaultUserAgent(): String = runCatching {
runBlocking {
userAgentLazy.get()
}
}.onFailure { e ->
e.printStackTraceDebug()
}.getOrDefault(UserAgents.FIREFOX_MOBILE)
override fun getConfig(source: MangaSource): MangaSourceConfig { override fun getConfig(source: MangaSource): MangaSourceConfig {
return SourceSettings(androidContext, source) return SourceSettings(androidContext, source)
} }
@@ -60,4 +78,12 @@ class MangaLoaderContextImpl @Inject constructor(
override fun getPreferredLocales(): List<Locale> { override fun getPreferredLocales(): List<Locale> {
return LocaleListCompat.getAdjustedDefault().toList() return LocaleListCompat.getAdjustedDefault().toList()
} }
@MainThread
private fun obtainWebView(): WebView {
return webViewCached?.get() ?: WebView(androidContext).also {
it.configureForParser(null)
webViewCached = WeakReference(it)
}
}
} }

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import android.util.Log import android.util.Log
import androidx.collection.MutableLongSet
import coil.request.CachePolicy import coil.request.CachePolicy
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -170,7 +171,11 @@ class RemoteMangaRepository(
return getConfig().isSlowdownEnabled return getConfig().isSlowdownEnabled
} }
private fun getConfig() = parser.config as SourceSettings fun invalidateCache() {
cache.clear(source)
}
fun getConfig() = parser.config as SourceSettings
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> { private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key] var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
@@ -189,7 +194,7 @@ class RemoteMangaRepository(
return emptyList() return emptyList()
} }
val result = ArrayList<MangaPage>(size) val result = ArrayList<MangaPage>(size)
val set = HashSet<Long>(size) val set = MutableLongSet(size)
for (page in this) { for (page in this) {
if (set.add(page.id)) { if (set.add(page.id)) {
result.add(page) result.add(page)
@@ -226,6 +231,5 @@ class RemoteMangaRepository(
} }
} }
private fun Result<*>.isValidResult() = exceptionOrNull() !is ParseException private fun Result<*>.isValidResult() = isSuccess && (getOrNull() as? Collection<*>)?.isEmpty() != true
&& (getOrNull() as? Collection<*>)?.isEmpty() != true
} }

View File

@@ -11,6 +11,7 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.ArraySet import androidx.collection.ArraySet
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import org.json.JSONArray import org.json.JSONArray
@@ -70,10 +71,17 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
} }
} }
val isNavLabelsVisible: Boolean
get() = prefs.getBoolean(KEY_NAV_LABELS, true)
var gridSize: Int var gridSize: Int
get() = prefs.getInt(KEY_GRID_SIZE, 100) get() = prefs.getInt(KEY_GRID_SIZE, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) } set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
var gridSizePages: Int
get() = prefs.getInt(KEY_GRID_SIZE_PAGES, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE_PAGES, value) }
var historyListMode: ListMode var historyListMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE_HISTORY, listMode) get() = prefs.getEnumValue(KEY_LIST_MODE_HISTORY, listMode)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_HISTORY, value) } set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_HISTORY, value) }
@@ -101,14 +109,21 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
} }
} }
val readerPageSwitch: Set<String> var isReaderDoubleOnLandscape: Boolean
get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS) get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
val isReaderVolumeButtonsEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_VOLUME_BUTTONS, false)
val isReaderZoomButtonsEnabled: Boolean val isReaderZoomButtonsEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_ZOOM_BUTTONS, false) get() = prefs.getBoolean(KEY_READER_ZOOM_BUTTONS, false)
val isReaderTapsAdaptive: Boolean val isReaderControlAlwaysLTR: Boolean
get() = !prefs.getBoolean(KEY_READER_TAPS_LTR, false) get() = prefs.getBoolean(KEY_READER_CONTROL_LTR, false)
val isReaderFullscreenEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_FULLSCREEN, true)
val isReaderOptimizationEnabled: Boolean val isReaderOptimizationEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false) get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false)
@@ -167,10 +182,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_INCOGNITO_MODE, false) get() = prefs.getBoolean(KEY_INCOGNITO_MODE, false)
set(value) = prefs.edit { putBoolean(KEY_INCOGNITO_MODE, value) } set(value) = prefs.edit { putBoolean(KEY_INCOGNITO_MODE, value) }
var chaptersReverse: Boolean var isChaptersReverse: Boolean
get() = prefs.getBoolean(KEY_REVERSE_CHAPTERS, false) get() = prefs.getBoolean(KEY_REVERSE_CHAPTERS, false)
set(value) = prefs.edit { putBoolean(KEY_REVERSE_CHAPTERS, value) } set(value) = prefs.edit { putBoolean(KEY_REVERSE_CHAPTERS, value) }
var isChaptersGridView: Boolean
get() = prefs.getBoolean(KEY_GRID_VIEW_CHAPTERS, false)
set(value) = prefs.edit { putBoolean(KEY_GRID_VIEW_CHAPTERS, value) }
val zoomMode: ZoomMode val zoomMode: ZoomMode
get() = prefs.getEnumValue(KEY_ZOOM_MODE, ZoomMode.FIT_CENTER) get() = prefs.getEnumValue(KEY_ZOOM_MODE, ZoomMode.FIT_CENTER)
@@ -180,11 +199,13 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
var appPassword: String? var appPassword: String?
get() = prefs.getString(KEY_APP_PASSWORD, null) get() = prefs.getString(KEY_APP_PASSWORD, null)
set(value) = prefs.edit { set(value) = prefs.edit {
if (value != null) putString(KEY_APP_PASSWORD, value) else remove( if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD)
KEY_APP_PASSWORD,
)
} }
var isAppPasswordNumeric: Boolean
get() = prefs.getBoolean(KEY_APP_PASSWORD_NUMERIC, false)
set(value) = prefs.edit { putBoolean(KEY_APP_PASSWORD_NUMERIC, value) }
val isLoggingEnabled: Boolean val isLoggingEnabled: Boolean
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false) get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
@@ -204,8 +225,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isUnstableUpdatesAllowed: Boolean val isUnstableUpdatesAllowed: Boolean
get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false) get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false)
val isPagesTabEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_TAB, true)
val defaultDetailsTab: Int val defaultDetailsTab: Int
get() = prefs.getString(KEY_DETAILS_TAB, null)?.toIntOrNull()?.coerceIn(0, 1) ?: 0 get() = if (isPagesTabEnabled) {
prefs.getString(KEY_DETAILS_TAB, null)?.toIntOrNull()?.coerceIn(0, 1) ?: 0
} else {
0
}
val isContentPrefetchEnabled: Boolean val isContentPrefetchEnabled: Boolean
get() { get() {
@@ -266,6 +294,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isDownloadsWiFiOnly: Boolean val isDownloadsWiFiOnly: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false) get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false)
val preferredDownloadFormat: DownloadFormat
get() = prefs.getEnumValue(KEY_DOWNLOADS_FORMAT, DownloadFormat.AUTOMATIC)
var isSuggestionsEnabled: Boolean var isSuggestionsEnabled: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS, false) get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) } set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) }
@@ -360,6 +391,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isWebtoonZoomEnable: Boolean val isWebtoonZoomEnable: Boolean
get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true) get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true)
@get:FloatRange(from = 0.0, to = 0.5)
val defaultWebtoonZoomOut: Float
get() = prefs.getInt(KEY_WEBTOON_ZOOM_OUT, 0).coerceIn(0, 50) / 100f
@get:FloatRange(from = 0.0, to = 1.0) @get:FloatRange(from = 0.0, to = 1.0)
var readerAutoscrollSpeed: Float var readerAutoscrollSpeed: Float
get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f) get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f)
@@ -395,6 +430,18 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull() get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull()
set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) } set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) }
val isReadingTimeEstimationEnabled: Boolean
get() = prefs.getBoolean(KEY_READING_TIME, true)
val isPagesSavingAskEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_SAVE_ASK, true)
val isStatsEnabled: Boolean
get() = prefs.getBoolean(KEY_STATS_ENABLED, false)
val isAutoLocalChaptersCleanupEnabled: Boolean
get() = prefs.getBoolean(KEY_CHAPTERS_CLEAR_AUTO, false)
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
} }
@@ -407,6 +454,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
prefs.edit { putStringSet(KEY_TIPS_CLOSED, closedTips + tip) } prefs.edit { putStringSet(KEY_TIPS_CLOSED, closedTips + tip) }
} }
fun getPagesSaveDir(context: Context): DocumentFile? =
prefs.getString(KEY_PAGES_SAVE_DIR, null)?.toUriOrNull()?.let {
DocumentFile.fromTreeUri(context, it)?.takeIf { it.canWrite() }
}
fun setPagesSaveDir(uri: Uri?) {
prefs.edit { putString(KEY_PAGES_SAVE_DIR, uri?.toString()) }
}
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) { fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener) prefs.registerOnSharedPreferenceChangeListener(listener)
} }
@@ -453,7 +509,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
companion object { companion object {
const val PAGE_SWITCH_TAPS = "taps"
const val PAGE_SWITCH_VOLUME_KEYS = "volume" const val PAGE_SWITCH_VOLUME_KEYS = "volume"
const val TRACK_HISTORY = "history" const val TRACK_HISTORY = "history"
@@ -470,14 +525,20 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear" const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear" const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear"
const val KEY_COOKIES_CLEAR = "cookies_clear" const val KEY_COOKIES_CLEAR = "cookies_clear"
const val KEY_CHAPTERS_CLEAR = "chapters_clear"
const val KEY_CHAPTERS_CLEAR_AUTO = "chapters_clear_auto"
const val KEY_THUMBS_CACHE_CLEAR = "thumbs_cache_clear" const val KEY_THUMBS_CACHE_CLEAR = "thumbs_cache_clear"
const val KEY_SEARCH_HISTORY_CLEAR = "search_history_clear" const val KEY_SEARCH_HISTORY_CLEAR = "search_history_clear"
const val KEY_UPDATES_FEED_CLEAR = "updates_feed_clear" const val KEY_UPDATES_FEED_CLEAR = "updates_feed_clear"
const val KEY_GRID_SIZE = "grid_size" const val KEY_GRID_SIZE = "grid_size"
const val KEY_GRID_SIZE_PAGES = "grid_size_pages"
const val KEY_REMOTE_SOURCES = "remote_sources" const val KEY_REMOTE_SOURCES = "remote_sources"
const val KEY_LOCAL_STORAGE = "local_storage" const val KEY_LOCAL_STORAGE = "local_storage"
const val KEY_READER_SWITCHERS = "reader_switchers" const val KEY_READER_DOUBLE_PAGES = "reader_double_pages"
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons" const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
const val KEY_READER_FULLSCREEN = "reader_fullscreen"
const val KEY_READER_VOLUME_BUTTONS = "reader_volume_buttons"
const val KEY_TRACKER_ENABLED = "tracker_enabled" const val KEY_TRACKER_ENABLED = "tracker_enabled"
const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi" const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi"
const val KEY_TRACK_SOURCES = "track_sources" const val KEY_TRACK_SOURCES = "track_sources"
@@ -493,6 +554,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
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_APP_PASSWORD = "app_password" const val KEY_APP_PASSWORD = "app_password"
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
const val KEY_PROTECT_APP = "protect_app" const val KEY_PROTECT_APP = "protect_app"
const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio" const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio"
const val KEY_APP_VERSION = "app_version" const val KEY_APP_VERSION = "app_version"
@@ -506,6 +568,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_HISTORY_GROUPING = "history_grouping" const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_READING_INDICATORS = "reading_indicators" const val KEY_READING_INDICATORS = "reading_indicators"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters" const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
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"
const val KEY_PAGES_NUMBERS = "pages_numbers" const val KEY_PAGES_NUMBERS = "pages_numbers"
const val KEY_SCREENSHOTS_POLICY = "screenshots_policy" const val KEY_SCREENSHOTS_POLICY = "screenshots_policy"
@@ -518,7 +581,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SHIKIMORI = "shikimori" const val KEY_SHIKIMORI = "shikimori"
const val KEY_ANILIST = "anilist" const val KEY_ANILIST = "anilist"
const val KEY_MAL = "mal" const val KEY_MAL = "mal"
const val KEY_KITSU = "kitsu"
const val KEY_DOWNLOADS_WIFI = "downloads_wifi" const val KEY_DOWNLOADS_WIFI = "downloads_wifi"
const val KEY_DOWNLOADS_FORMAT = "downloads_format"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
const val KEY_DOH = "doh" const val KEY_DOH = "doh"
const val KEY_EXIT_CONFIRM = "exit_confirm" const val KEY_EXIT_CONFIRM = "exit_confirm"
@@ -530,12 +595,13 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_BACKGROUND = "reader_background" const val KEY_READER_BACKGROUND = "reader_background"
const val KEY_READER_SCREEN_ON = "reader_screen_on" const val KEY_READER_SCREEN_ON = "reader_screen_on"
const val KEY_SHORTCUTS = "dynamic_shortcuts" const val KEY_SHORTCUTS = "dynamic_shortcuts"
const val KEY_READER_TAPS_LTR = "reader_taps_ltr" const val KEY_READER_TAP_ACTIONS = "reader_tap_actions"
const val KEY_READER_OPTIMIZE = "reader_optimize" const val KEY_READER_OPTIMIZE = "reader_optimize"
const val KEY_LOCAL_LIST_ORDER = "local_order" const val KEY_LOCAL_LIST_ORDER = "local_order"
const val KEY_HISTORY_ORDER = "history_order" const val KEY_HISTORY_ORDER = "history_order"
const val KEY_FAVORITES_ORDER = "fav_order" const val KEY_FAVORITES_ORDER = "fav_order"
const val KEY_WEBTOON_ZOOM = "webtoon_zoom" const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
const val KEY_WEBTOON_ZOOM_OUT = "webtoon_zoom_out"
const val KEY_PREFETCH_CONTENT = "prefetch_content" const val KEY_PREFETCH_CONTENT = "prefetch_content"
const val KEY_APP_LOCALE = "app_locale" const val KEY_APP_LOCALE = "app_locale"
const val KEY_LOGGING_ENABLED = "logging" const val KEY_LOGGING_ENABLED = "logging"
@@ -559,6 +625,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_DISABLE_NSFW = "no_nsfw" const val KEY_DISABLE_NSFW = "no_nsfw"
const val KEY_RELATED_MANGA = "related_manga" const val KEY_RELATED_MANGA = "related_manga"
const val KEY_NAV_MAIN = "nav_main" const val KEY_NAV_MAIN = "nav_main"
const val KEY_NAV_LABELS = "nav_labels"
const val KEY_32BIT_COLOR = "enhanced_colors" const val KEY_32BIT_COLOR = "enhanced_colors"
const val KEY_SOURCES_ORDER = "sources_sort_order" const val KEY_SOURCES_ORDER = "sources_sort_order"
const val KEY_SOURCES_CATALOG = "sources_catalog" const val KEY_SOURCES_CATALOG = "sources_catalog"
@@ -567,9 +634,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_CF_INVERTED = "cf_inverted" const val KEY_CF_INVERTED = "cf_inverted"
const val KEY_CF_GRAYSCALE = "cf_grayscale" const val KEY_CF_GRAYSCALE = "cf_grayscale"
const val KEY_IGNORE_DOZE = "ignore_dose" const val KEY_IGNORE_DOZE = "ignore_dose"
const val KEY_PAGES_TAB = "pages_tab"
const val KEY_DETAILS_TAB = "details_tab" const val KEY_DETAILS_TAB = "details_tab"
const val KEY_READING_TIME = "reading_time"
// About const val KEY_PAGES_SAVE_DIR = "pages_dir"
const val KEY_PAGES_SAVE_ASK = "pages_dir_ask"
const val KEY_STATS_ENABLED = "stats_on"
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"
} }

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.core.prefs
enum class DownloadFormat {
AUTOMATIC,
SINGLE_CBZ,
MULTIPLE_CBZ,
}

View File

@@ -4,13 +4,12 @@ import androidx.annotation.DrawableRes
import androidx.annotation.IdRes import androidx.annotation.IdRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.model.ListModel
enum class NavItem( enum class NavItem(
@IdRes val id: Int, @IdRes val id: Int,
@StringRes val title: Int, @StringRes val title: Int,
@DrawableRes val icon: Int, @DrawableRes val icon: Int,
) : ListModel { ) {
HISTORY(R.id.nav_history, R.string.history, R.drawable.ic_history_selector), HISTORY(R.id.nav_history, R.string.history, R.drawable.ic_history_selector),
FAVORITES(R.id.nav_favorites, R.string.favourites, R.drawable.ic_favourites_selector), FAVORITES(R.id.nav_favorites, R.string.favourites, R.drawable.ic_favourites_selector),
@@ -21,10 +20,6 @@ enum class NavItem(
BOOKMARKS(R.id.nav_bookmarks, R.string.bookmarks, R.drawable.ic_bookmark_selector), BOOKMARKS(R.id.nav_bookmarks, R.string.bookmarks, R.drawable.ic_bookmark_selector),
; ;
override fun areItemsTheSame(other: ListModel): Boolean {
return other is NavItem && ordinal == other.ordinal
}
fun isAvailable(settings: AppSettings): Boolean = when (this) { fun isAvailable(settings: AppSettings): Boolean = when (this) {
SUGGESTIONS -> settings.isSuggestionsEnabled SUGGESTIONS -> settings.isSuggestionsEnabled
FEED -> settings.isTrackerEnabled FEED -> settings.isTrackerEnabled

View File

@@ -4,7 +4,9 @@ enum class ReaderMode(val id: Int) {
STANDARD(1), STANDARD(1),
REVERSED(3), REVERSED(3),
WEBTOON(2); VERTICAL(4),
WEBTOON(2),
;
companion object { companion object {

View File

@@ -1,18 +1,18 @@
package org.koitharu.kotatsu.core.prefs package org.koitharu.kotatsu.core.prefs
import android.content.Context import android.content.Context
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import androidx.core.content.edit import androidx.core.content.edit
import okhttp3.internal.isSensitiveHeader
import org.koitharu.kotatsu.core.util.ext.getEnumValue import org.koitharu.kotatsu.core.util.ext.getEnumValue
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
private const val KEY_SORT_ORDER = "sort_order"
private const val KEY_SLOWDOWN = "slowdown"
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig { class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE)
@@ -27,9 +27,13 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T> get(key: ConfigKey<T>): T { override fun <T> get(key: ConfigKey<T>): T {
return when (key) { return when (key) {
is ConfigKey.UserAgent -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue } is ConfigKey.UserAgent -> prefs.getString(key.key, key.defaultValue)
.ifNullOrEmpty { key.defaultValue }
.sanitizeHeaderValue()
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue } is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
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)
} as T } as T
} }
@@ -37,7 +41,22 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
when (key) { when (key) {
is ConfigKey.Domain -> putString(key.key, value as String?) is ConfigKey.Domain -> putString(key.key, value as String?)
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?) is ConfigKey.UserAgent -> putString(key.key, (value as String?)?.sanitizeHeaderValue())
is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean)
} }
} }
fun subscribe(listener: OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener)
}
fun unsubscribe(listener: OnSharedPreferenceChangeListener) {
prefs.unregisterOnSharedPreferenceChangeListener(listener)
}
companion object {
const val KEY_SORT_ORDER = "sort_order"
const val KEY_SLOWDOWN = "slowdown"
}
} }

View File

@@ -8,6 +8,7 @@ import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
@@ -29,6 +30,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.ui.util.BaseActivityEntryPoint import org.koitharu.kotatsu.core.ui.util.BaseActivityEntryPoint
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
@Suppress("LeakingThis") @Suppress("LeakingThis")
abstract class BaseActivity<B : ViewBinding> : abstract class BaseActivity<B : ViewBinding> :
@@ -58,11 +60,11 @@ abstract class BaseActivity<B : ViewBinding> :
if (isAmoledTheme) { if (isAmoledTheme) {
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled) setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
} }
putDataToExtras(intent)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
insetsDelegate.handleImeInsets = true insetsDelegate.handleImeInsets = true
insetsDelegate.addInsetsListener(this) insetsDelegate.addInsetsListener(this)
putDataToExtras(intent)
} }
override fun onPostCreate(savedInstanceState: Bundle?) { override fun onPostCreate(savedInstanceState: Bundle?) {
@@ -125,11 +127,13 @@ abstract class BaseActivity<B : ViewBinding> :
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ColorUtils.compositeColors( ColorUtils.compositeColors(
ContextCompat.getColor(this, com.google.android.material.R.color.m3_appbar_overlay_color), ContextCompat.getColor(this, com.google.android.material.R.color.m3_appbar_overlay_color),
getThemeColor(R.attr.m3ColorBackground), getThemeColor(com.google.android.material.R.attr.colorSurface),
) )
} else { } else {
ContextCompat.getColor(this, R.color.kotatsu_m3_background) ContextCompat.getColor(this, R.color.kotatsu_background)
} }
defaultStatusBarColor = window.statusBarColor
window.statusBarColor = actionModeColor
val insets = ViewCompat.getRootWindowInsets(viewBinding.root) val insets = ViewCompat.getRootWindowInsets(viewBinding.root)
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return ?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar).apply { findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar).apply {
@@ -138,8 +142,6 @@ abstract class BaseActivity<B : ViewBinding> :
topMargin = insets.top topMargin = insets.top
} }
} }
defaultStatusBarColor = window.statusBarColor
window.statusBarColor = actionModeColor
} }
@CallSuper @CallSuper
@@ -164,6 +166,21 @@ abstract class BaseActivity<B : ViewBinding> :
intent?.putExtra(EXTRA_DATA, intent.data) intent?.putExtra(EXTRA_DATA, intent.data)
} }
protected fun setContentViewWebViewSafe(viewBindingProducer: () -> B): Boolean {
return try {
setContentView(viewBindingProducer())
true
} catch (e: Exception) {
if (e.isWebViewUnavailable()) {
Toast.makeText(this, R.string.web_view_unavailable, Toast.LENGTH_LONG).show()
finishAfterTransition()
false
} else {
throw e
}
}
}
companion object { companion object {
const val EXTRA_DATA = "data" const val EXTRA_DATA = "data"

View File

@@ -29,7 +29,6 @@ abstract class BaseFullscreenActivity<B : ViewBinding> :
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
} }
} }
// insetsControllerCompat.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
systemUiController.setSystemUiVisible(true) systemUiController.setSystemUiVisible(true)
} }
} }

View File

@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListItemType
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 kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
@@ -28,11 +29,23 @@ open class BaseListAdapter<T : ListModel> : AsyncListDifferDelegationAdapter<T>(
return this return this
} }
fun addListListener(listListener: ListListener<T>) { fun addListListener(listListener: ListListener<T>): BaseListAdapter<T> {
differ.addListListener(listListener) differ.addListListener(listListener)
return this
} }
fun removeListListener(listListener: ListListener<T>) { fun removeListListener(listListener: ListListener<T>) {
differ.removeListListener(listListener) differ.removeListListener(listListener)
} }
fun findHeader(position: Int): ListHeader? {
val snapshot = items
for (i in (0..position).reversed()) {
val item = snapshot.getOrNull(i) ?: continue
if (item is ListHeader) {
return item
}
}
return null
}
} }

View File

@@ -68,6 +68,13 @@ abstract class BaseViewModel : ViewModel() {
errorEvent.call(error) errorEvent.call(error)
} }
protected inline suspend fun <T> withLoading(block: () -> T): T = try {
loadingCounter.increment()
block()
} finally {
loadingCounter.decrement()
}
protected fun MutableStateFlow<Int>.increment() = update { it + 1 } protected fun MutableStateFlow<Int>.increment() = update { it + 1 }
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 } protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }

View File

@@ -68,6 +68,14 @@ class RecyclerViewAlertDialog private constructor(
return this return this
} }
fun setNeutralButton(
@StringRes textId: Int,
listener: DialogInterface.OnClickListener,
): Builder<T> {
delegate.setNeutralButton(textId, listener)
return this
}
fun setCancelable(isCancelable: Boolean): Builder<T> { fun setCancelable(isCancelable: Boolean): Builder<T> {
delegate.setCancelable(isCancelable) delegate.setCancelable(isCancelable)
return this return this

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.core.ui.image
import android.graphics.drawable.Drawable
import coil.target.GenericViewTarget
import com.google.android.material.chip.Chip
class ChipIconTarget(override val view: Chip) : GenericViewTarget<Chip>() {
override var drawable: Drawable?
get() = view.chipIcon
set(value) {
view.chipIcon = value
}
}

View File

@@ -12,11 +12,10 @@ import android.graphics.RectF
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import androidx.annotation.StyleRes import androidx.annotation.StyleRes
import androidx.core.content.withStyledAttributes import androidx.core.content.withStyledAttributes
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.withClip import androidx.core.graphics.withClip
import com.google.android.material.color.MaterialColors import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import kotlin.math.absoluteValue import org.koitharu.kotatsu.core.util.KotatsuColors
class FaviconDrawable( class FaviconDrawable(
context: Context, context: Context,
@@ -44,7 +43,7 @@ class FaviconDrawable(
} }
paint.textAlign = Paint.Align.CENTER paint.textAlign = Paint.Align.CENTER
paint.isFakeBoldText = true paint.isFakeBoldText = true
colorForeground = MaterialColors.harmonize(colorOfString(name), colorBackground) colorForeground = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground)
} }
override fun draw(canvas: Canvas) { override fun draw(canvas: Canvas) {
@@ -104,9 +103,4 @@ class FaviconDrawable(
paint.getTextBounds(text, 0, text.length, tempRect) paint.getTextBounds(text, 0, text.length, tempRect)
return testTextSize * width / tempRect.width() return testTextSize * width / tempRect.width()
} }
private fun colorOfString(str: String): Int {
val hue = (str.hashCode() % 360).absoluteValue.toFloat()
return ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
}
} }

View File

@@ -1,10 +1,9 @@
package org.koitharu.kotatsu.core.ui.list package org.koitharu.kotatsu.core.ui.list
import android.app.Activity
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.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
@@ -20,7 +19,7 @@ private const val KEY_SELECTION = "selection"
private const val PROVIDER_NAME = "selection_decoration" private const val PROVIDER_NAME = "selection_decoration"
class ListSelectionController( class ListSelectionController(
private val activity: Activity, private val appCompatDelegate: AppCompatDelegate,
private val decoration: AbstractSelectionItemDecoration, private val decoration: AbstractSelectionItemDecoration,
private val registryOwner: SavedStateRegistryOwner, private val registryOwner: SavedStateRegistryOwner,
private val callback: Callback2, private val callback: Callback2,
@@ -108,7 +107,7 @@ class ListSelectionController(
private fun startActionMode() { private fun startActionMode() {
if (actionMode == null) { if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this) actionMode = appCompatDelegate.startSupportActionMode(this)
} }
} }

View File

@@ -91,7 +91,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
canvas.restoreToCount(checkpoint) canvas.restoreToCount(checkpoint)
} }
protected open fun getItemId(parent: RecyclerView, child: View) = parent.getChildItemId(child) abstract fun getItemId(parent: RecyclerView, child: View): Long
protected open fun onDrawBackground( protected open fun onDrawBackground(
canvas: Canvas, canvas: Canvas,

View File

@@ -1,25 +1,39 @@
package org.koitharu.kotatsu.core.ui.sheet package org.koitharu.kotatsu.core.ui.sheet
import android.app.Dialog import android.app.Dialog
import android.content.Context
import android.graphics.Color
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams import android.view.ViewGroup.LayoutParams
import androidx.activity.OnBackPressedDispatcher import androidx.activity.OnBackPressedDispatcher
import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatDialog
import androidx.appcompat.app.AppCompatDialogFragment import androidx.appcompat.app.AppCompatDialogFragment
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.ActionBarContextView
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.sidesheet.SideSheetDialog import com.google.android.material.sidesheet.SideSheetDialog
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() { abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
private var waitingForDismissAllowingStateLoss = false private var waitingForDismissAllowingStateLoss = false
private var isFitToContentsDisabled = false private var isFitToContentsDisabled = false
private var defaultStatusBarColor = Color.TRANSPARENT
var viewBinding: B? = null var viewBinding: B? = null
private set private set
@@ -31,12 +45,19 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
protected val behavior: AdaptiveSheetBehavior? protected val behavior: AdaptiveSheetBehavior?
get() = AdaptiveSheetBehavior.from(dialog) get() = AdaptiveSheetBehavior.from(dialog)
@JvmField
val actionModeDelegate = ActionModeDelegate()
val isExpanded: Boolean val isExpanded: Boolean
get() = behavior?.state == AdaptiveSheetBehavior.STATE_EXPANDED get() = behavior?.state == AdaptiveSheetBehavior.STATE_EXPANDED
val onBackPressedDispatcher: OnBackPressedDispatcher val onBackPressedDispatcher: OnBackPressedDispatcher
get() = requireComponentDialog().onBackPressedDispatcher get() = requireComponentDialog().onBackPressedDispatcher
var isLocked = false
private set
private var lockCounter = 0
final override fun onCreateView( final override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@@ -60,11 +81,45 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val context = requireContext() val context = requireContext()
return if (context.resources.getBoolean(R.bool.is_tablet)) { val dialog = if (context.resources.getBoolean(R.bool.is_tablet)) {
SideSheetDialog(context, theme) SideSheetDialogImpl(context, theme)
} else { } else {
BottomSheetDialog(context, theme) BottomSheetDialogImpl(context, theme)
} }
dialog.onBackPressedDispatcher.addCallback(actionModeDelegate)
return dialog
}
@CallSuper
protected open fun dispatchSupportActionModeStarted(mode: ActionMode) {
actionModeDelegate.onSupportActionModeStarted(mode)
val ctx = requireContext()
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ColorUtils.compositeColors(
ContextCompat.getColor(ctx, com.google.android.material.R.color.m3_appbar_overlay_color),
ctx.getThemeColor(com.google.android.material.R.attr.colorSurface),
)
} else {
ContextCompat.getColor(ctx, R.color.kotatsu_surface)
}
dialog?.window?.let {
defaultStatusBarColor = it.statusBarColor
it.statusBarColor = actionModeColor
}
val insets = ViewCompat.getRootWindowInsets(requireView())
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
dialog?.window?.decorView?.findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar)?.apply {
setBackgroundColor(actionModeColor)
updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
}
}
@CallSuper
protected open fun dispatchSupportActionModeFinished(mode: ActionMode) {
actionModeDelegate.onSupportActionModeFinished(mode)
dialog?.window?.statusBarColor = defaultStatusBarColor
} }
fun addSheetCallback(callback: AdaptiveSheetCallback) { fun addSheetCallback(callback: AdaptiveSheetCallback) {
@@ -81,7 +136,16 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit
fun startSupportActionMode(callback: ActionMode.Callback): ActionMode? {
val appCompatDialog = dialog as? AppCompatDialog ?: return null
return appCompatDialog.delegate.startSupportActionMode(callback)
}
protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) { protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) {
this.isLocked = isLocked
if (!isLocked) {
lockCounter = 0
}
val b = behavior ?: return val b = behavior ?: return
if (isExpanded) { if (isExpanded) {
b.state = BottomSheetBehavior.STATE_EXPANDED b.state = BottomSheetBehavior.STATE_EXPANDED
@@ -109,6 +173,20 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
} }
} }
@CallSuper
open fun expandAndLock() {
lockCounter++
setExpanded(isExpanded = true, isLocked = true)
}
@CallSuper
open fun unlock() {
lockCounter--
if (lockCounter <= 0) {
setExpanded(isExpanded, false)
}
}
fun requireViewBinding(): B = checkNotNull(viewBinding) { fun requireViewBinding(): B = checkNotNull(viewBinding) {
"Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()." "Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()."
} }
@@ -171,4 +249,38 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
override fun onSlide(sheet: View, slideOffset: Float) {} override fun onSlide(sheet: View, slideOffset: Float) {}
} }
private inner class SideSheetDialogImpl(context: Context, theme: Int) : SideSheetDialog(context, theme) {
override fun onSupportActionModeStarted(mode: ActionMode?) {
super.onSupportActionModeStarted(mode)
if (mode != null) {
dispatchSupportActionModeStarted(mode)
}
}
override fun onSupportActionModeFinished(mode: ActionMode?) {
super.onSupportActionModeFinished(mode)
if (mode != null) {
dispatchSupportActionModeFinished(mode)
}
}
}
private inner class BottomSheetDialogImpl(context: Context, theme: Int) : BottomSheetDialog(context, theme) {
override fun onSupportActionModeStarted(mode: ActionMode?) {
super.onSupportActionModeStarted(mode)
if (mode != null) {
dispatchSupportActionModeStarted(mode)
}
}
override fun onSupportActionModeFinished(mode: ActionMode?) {
super.onSupportActionModeFinished(mode)
if (mode != null) {
dispatchSupportActionModeFinished(mode)
}
}
}
} }

View File

@@ -7,12 +7,16 @@ import org.koitharu.kotatsu.R
class ReversibleActionObserver( class ReversibleActionObserver(
private val snackbarHost: View, private val snackbarHost: View,
private val snackbarAnchor: View? = null,
) : FlowCollector<ReversibleAction> { ) : FlowCollector<ReversibleAction> {
override suspend fun emit(value: ReversibleAction) { override suspend fun emit(value: ReversibleAction) {
val handle = value.handle val handle = value.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
val snackbar = Snackbar.make(snackbarHost, value.stringResId, length) val snackbar = Snackbar.make(snackbarHost, value.stringResId, length)
if (snackbarAnchor?.isShown == true) {
snackbar.anchorView = snackbarAnchor
}
if (handle != null) { if (handle != null) {
snackbar.setAction(R.string.undo) { handle.reverseAsync() } snackbar.setAction(R.string.undo) { handle.reverseAsync() }
} }

View File

@@ -10,8 +10,8 @@ import com.google.android.material.imageview.ShapeableImageView
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import kotlin.math.roundToInt import kotlin.math.roundToInt
private const val ASPECT_RATIO_HEIGHT = 18f private const val ASPECT_RATIO_HEIGHT = 3f
private const val ASPECT_RATIO_WIDTH = 13f private const val ASPECT_RATIO_WIDTH = 2f
class CoverImageView @JvmOverloads constructor( class CoverImageView @JvmOverloads constructor(
context: Context, context: Context,

View File

@@ -1,8 +1,8 @@
package org.koitharu.kotatsu.core.ui.widgets package org.koitharu.kotatsu.core.ui.widgets
import android.content.Context import android.content.Context
import android.util.ArrayMap
import android.util.AttributeSet import android.util.AttributeSet
import androidx.collection.MutableScatterMap
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import kotlin.math.cbrt import kotlin.math.cbrt
import kotlin.math.pow import kotlin.math.pow
@@ -12,7 +12,7 @@ class CubicSlider @JvmOverloads constructor(
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
) : Slider(context, attrs) { ) : Slider(context, attrs) {
private val changeListeners = ArrayMap<OnChangeListener, OnChangeListenerMapper>(1) private val changeListeners = MutableScatterMap<OnChangeListener, OnChangeListenerMapper>(1)
override fun setValue(value: Float) { override fun setValue(value: Float) {
super.setValue(value.unmap()) super.setValue(value.unmap())

View File

@@ -1,397 +0,0 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.CornerPathEffect
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.Typeface
import android.os.Build
import android.os.Parcelable
import android.text.Layout
import android.text.StaticLayout
import android.text.TextDirectionHeuristic
import android.text.TextDirectionHeuristics
import android.text.TextPaint
import android.util.AttributeSet
import android.view.View
import androidx.annotation.RequiresApi
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.draw
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.resolveSp
class PieChart @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr), PieChartInterface {
private var marginTextFirst: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_1)
private var marginTextSecond: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_2)
private var marginTextThird: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_3)
private var marginSmallCircle: Float = context.resources.resolveDp(DEFAULT_MARGIN_SMALL_CIRCLE)
private val marginText: Float = marginTextFirst + marginTextSecond
private val circleRect = RectF()
private var circleStrokeWidth: Float = context.resources.resolveDp(6f)
private var circleRadius: Float = 0f
private var circlePadding: Float = context.resources.resolveDp(8f)
private var circlePaintRoundSize: Boolean = true
private var circleSectionSpace: Float = 3f
private var circleCenterX: Float = 0f
private var circleCenterY: Float = 0f
private var numberTextPaint: TextPaint = TextPaint()
private var descriptionTextPain: TextPaint = TextPaint()
private var amountTextPaint: TextPaint = TextPaint()
private var textStartX: Float = 0f
private var textStartY: Float = 0f
private var textHeight: Int = 0
private var textCircleRadius: Float = context.resources.resolveDp(4f)
private var textAmountStr: String = ""
private var textAmountY: Float = 0f
private var textAmountXNumber: Float = 0f
private var textAmountXDescription: Float = 0f
private var textAmountYDescription: Float = 0f
private var totalAmount: Int = 0
private var pieChartColors: List<String> = listOf()
private var percentageCircleList: List<PieChartModel> = listOf()
private var textRowList: MutableList<StaticLayout> = mutableListOf()
private var dataList: List<Pair<Int, String>> = listOf()
private var animationSweepAngle: Int = 0
init {
var textAmountSize: Float = context.resources.resolveSp(22f)
var textNumberSize: Float = context.resources.resolveSp(20f)
var textDescriptionSize: Float = context.resources.resolveSp(14f)
var textAmountColor: Int = Color.WHITE
var textNumberColor: Int = Color.WHITE
var textDescriptionColor: Int = Color.GRAY
if (attrs != null) {
val typeArray = context.obtainStyledAttributes(attrs, R.styleable.PieChart)
val colorResId = typeArray.getResourceId(R.styleable.PieChart_pieChartColors, 0)
pieChartColors = typeArray.resources.getStringArray(colorResId).toList()
marginTextFirst = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextFirst, marginTextFirst)
marginTextSecond = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextSecond, marginTextSecond)
marginTextThird = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextThird, marginTextThird)
marginSmallCircle =
typeArray.getDimension(R.styleable.PieChart_pieChartMarginSmallCircle, marginSmallCircle)
circleStrokeWidth =
typeArray.getDimension(R.styleable.PieChart_pieChartCircleStrokeWidth, circleStrokeWidth)
circlePadding = typeArray.getDimension(R.styleable.PieChart_pieChartCirclePadding, circlePadding)
circlePaintRoundSize =
typeArray.getBoolean(R.styleable.PieChart_pieChartCirclePaintRoundSize, circlePaintRoundSize)
circleSectionSpace = typeArray.getFloat(R.styleable.PieChart_pieChartCircleSectionSpace, circleSectionSpace)
textCircleRadius = typeArray.getDimension(R.styleable.PieChart_pieChartTextCircleRadius, textCircleRadius)
textAmountSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextAmountSize, textAmountSize)
textNumberSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextNumberSize, textNumberSize)
textDescriptionSize =
typeArray.getDimension(R.styleable.PieChart_pieChartTextDescriptionSize, textDescriptionSize)
textAmountColor = typeArray.getColor(R.styleable.PieChart_pieChartTextAmountColor, textAmountColor)
textNumberColor = typeArray.getColor(R.styleable.PieChart_pieChartTextNumberColor, textNumberColor)
textDescriptionColor =
typeArray.getColor(R.styleable.PieChart_pieChartTextDescriptionColor, textDescriptionColor)
textAmountStr = typeArray.getString(R.styleable.PieChart_pieChartTextAmount) ?: ""
typeArray.recycle()
}
circlePadding += circleStrokeWidth
// Инициализация кистей View
initPaints(amountTextPaint, textAmountSize, textAmountColor)
initPaints(numberTextPaint, textNumberSize, textNumberColor)
initPaints(descriptionTextPain, textDescriptionSize, textDescriptionColor, true)
}
@RequiresApi(Build.VERSION_CODES.M)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
textRowList.clear()
val initSizeWidth = resolveDefaultSize(widthMeasureSpec, DEFAULT_VIEW_SIZE_WIDTH)
val textTextWidth = (initSizeWidth * TEXT_WIDTH_PERCENT)
val initSizeHeight = calculateViewHeight(heightMeasureSpec, textTextWidth.toInt())
textStartX = initSizeWidth - textTextWidth.toFloat()
textStartY = initSizeHeight.toFloat() / 2 - textHeight / 2
calculateCircleRadius(initSizeWidth, initSizeHeight)
setMeasuredDimension(initSizeWidth, initSizeHeight)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
drawCircle(canvas)
drawText(canvas)
}
override fun onRestoreInstanceState(state: Parcelable?) {
val pieChartState = state as? PieChartState
super.onRestoreInstanceState(pieChartState?.superState ?: state)
dataList = pieChartState?.dataList ?: listOf()
}
override fun onSaveInstanceState(): Parcelable {
val superState = super.onSaveInstanceState()
return PieChartState(superState, dataList)
}
override fun setDataChart(list: List<Pair<Int, String>>) {
dataList = list
calculatePercentageOfData()
}
override fun startAnimation() {
val animator = ValueAnimator.ofInt(0, 360).apply {
duration = context.getAnimationDuration(android.R.integer.config_longAnimTime)
interpolator = FastOutSlowInInterpolator()
addUpdateListener { valueAnimator ->
animationSweepAngle = valueAnimator.animatedValue as Int
invalidate()
}
}
animator.start()
}
private fun drawCircle(canvas: Canvas) {
for (percent in percentageCircleList) {
if (animationSweepAngle > percent.percentToStartAt + percent.percentOfCircle) {
canvas.drawArc(circleRect, percent.percentToStartAt, percent.percentOfCircle, false, percent.paint)
} else if (animationSweepAngle > percent.percentToStartAt) {
canvas.drawArc(
circleRect,
percent.percentToStartAt,
animationSweepAngle - percent.percentToStartAt,
false,
percent.paint,
)
}
}
}
private fun drawText(canvas: Canvas) {
var textBuffY = textStartY
textRowList.forEachIndexed { index, staticLayout ->
if (index % 2 == 0) {
staticLayout.draw(canvas, textStartX + marginSmallCircle + textCircleRadius, textBuffY)
canvas.drawCircle(
textStartX + marginSmallCircle / 2,
textBuffY + staticLayout.height / 2 + textCircleRadius / 2,
textCircleRadius,
Paint().apply { color = Color.parseColor(pieChartColors[(index / 2) % pieChartColors.size]) },
)
textBuffY += staticLayout.height + marginTextFirst
} else {
staticLayout.draw(canvas, textStartX, textBuffY)
textBuffY += staticLayout.height + marginTextSecond
}
}
canvas.drawText(totalAmount.toString(), textAmountXNumber, textAmountY, amountTextPaint)
canvas.drawText(textAmountStr, textAmountXDescription, textAmountYDescription, descriptionTextPain)
}
private fun initPaints(textPaint: TextPaint, textSize: Float, textColor: Int, isDescription: Boolean = false) {
textPaint.color = textColor
textPaint.textSize = textSize
textPaint.isAntiAlias = true
if (!isDescription) textPaint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
}
private fun resolveDefaultSize(spec: Int, defValue: Int): Int {
return when (MeasureSpec.getMode(spec)) {
MeasureSpec.UNSPECIFIED -> resources.resolveDp(defValue)
else -> MeasureSpec.getSize(spec)
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun calculateViewHeight(heightMeasureSpec: Int, textWidth: Int): Int {
val initSizeHeight = resolveDefaultSize(heightMeasureSpec, DEFAULT_VIEW_SIZE_HEIGHT)
textHeight = (dataList.size * marginText + getTextViewHeight(textWidth)).toInt()
val textHeightWithPadding = textHeight + paddingTop + paddingBottom
return if (textHeightWithPadding > initSizeHeight) textHeightWithPadding else initSizeHeight
}
private fun calculateCircleRadius(width: Int, height: Int) {
val circleViewWidth = (width * CIRCLE_WIDTH_PERCENT)
circleRadius = if (circleViewWidth > height) {
(height.toFloat() - circlePadding) / 2
} else {
circleViewWidth.toFloat() / 2
}
with(circleRect) {
left = circlePadding
top = height / 2 - circleRadius
right = circleRadius * 2 + circlePadding
bottom = height / 2 + circleRadius
}
circleCenterX = (circleRadius * 2 + circlePadding + circlePadding) / 2
circleCenterY = (height / 2 + circleRadius + (height / 2 - circleRadius)) / 2
textAmountY = circleCenterY
val sizeTextAmountNumber = getWidthOfAmountText(
totalAmount.toString(),
amountTextPaint,
)
textAmountXNumber = circleCenterX - sizeTextAmountNumber.width() / 2
textAmountXDescription = circleCenterX - getWidthOfAmountText(textAmountStr, descriptionTextPain).width() / 2
textAmountYDescription = circleCenterY + sizeTextAmountNumber.height() + marginTextThird
}
@RequiresApi(Build.VERSION_CODES.M)
private fun getTextViewHeight(maxWidth: Int): Int {
var textHeight = 0
dataList.forEach {
val textLayoutNumber = getMultilineText(
text = it.first.toString(),
textPaint = numberTextPaint,
width = maxWidth,
)
val textLayoutDescription = getMultilineText(
text = it.second,
textPaint = descriptionTextPain,
width = maxWidth,
)
textRowList.apply {
add(textLayoutNumber)
add(textLayoutDescription)
}
textHeight += textLayoutNumber.height + textLayoutDescription.height
}
return textHeight
}
private fun calculatePercentageOfData() {
totalAmount = dataList.fold(0) { res, value -> res + value.first }
var startAt = circleSectionSpace
percentageCircleList = dataList.mapIndexed { index, pair ->
var percent = pair.first * 100 / totalAmount.toFloat() - circleSectionSpace
percent = if (percent < 0f) 0f else percent
val resultModel = PieChartModel(
percentOfCircle = percent,
percentToStartAt = startAt,
colorOfLine = Color.parseColor(pieChartColors[index % pieChartColors.size]),
stroke = circleStrokeWidth,
paintRound = circlePaintRoundSize,
)
if (percent != 0f) startAt += percent + circleSectionSpace
resultModel
}
}
private fun getWidthOfAmountText(text: String, textPaint: TextPaint): Rect {
val bounds = Rect()
textPaint.getTextBounds(text, 0, text.length, bounds)
return bounds
}
@RequiresApi(Build.VERSION_CODES.M)
private fun getMultilineText(
text: CharSequence,
textPaint: TextPaint,
width: Int,
start: Int = 0,
end: Int = text.length,
alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL,
textDir: TextDirectionHeuristic = TextDirectionHeuristics.LTR,
spacingMult: Float = 1f,
spacingAdd: Float = 0f
): StaticLayout {
return StaticLayout.Builder
.obtain(text, start, end, textPaint, width)
.setAlignment(alignment)
.setTextDirection(textDir)
.setLineSpacing(spacingAdd, spacingMult)
.build()
}
companion object {
private const val DEFAULT_MARGIN_TEXT_1 = 2f
private const val DEFAULT_MARGIN_TEXT_2 = 10f
private const val DEFAULT_MARGIN_TEXT_3 = 2f
private const val DEFAULT_MARGIN_SMALL_CIRCLE = 12f
private const val TEXT_WIDTH_PERCENT = 0.40
private const val CIRCLE_WIDTH_PERCENT = 0.50
const val DEFAULT_VIEW_SIZE_HEIGHT = 150
const val DEFAULT_VIEW_SIZE_WIDTH = 250
}
}
interface PieChartInterface {
fun setDataChart(list: List<Pair<Int, String>>)
fun startAnimation()
}
data class PieChartModel(
var percentOfCircle: Float = 0f,
var percentToStartAt: Float = 0f,
var colorOfLine: Int = 0,
var stroke: Float = 0f,
var paint: Paint = Paint(),
var paintRound: Boolean = true
) {
init {
if (percentOfCircle < 0 || percentOfCircle > 100) {
percentOfCircle = 100f
}
percentOfCircle = 360 * percentOfCircle / 100
if (percentToStartAt < 0 || percentToStartAt > 100) {
percentToStartAt = 0f
}
percentToStartAt = 360 * percentToStartAt / 100
if (colorOfLine == 0) {
colorOfLine = Color.parseColor("#000000")
}
paint = Paint()
paint.color = colorOfLine
paint.isAntiAlias = true
paint.style = Paint.Style.STROKE
paint.strokeWidth = stroke
paint.isDither = true
if (paintRound) {
paint.strokeJoin = Paint.Join.ROUND
paint.strokeCap = Paint.Cap.ROUND
paint.pathEffect = CornerPathEffect(8f)
}
}
}
class PieChartState(
superSavedState: Parcelable?,
val dataList: List<Pair<Int, String>>
) : View.BaseSavedState(superSavedState), Parcelable

View File

@@ -0,0 +1,155 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Outline
import android.graphics.Paint
import android.util.AttributeSet
import android.view.Gravity
import android.view.View
import android.view.ViewOutlineProvider
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.appcompat.widget.LinearLayoutCompat
import androidx.core.content.withStyledAttributes
import androidx.core.view.children
import androidx.core.widget.TextViewCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import com.google.android.material.R as materialR
class ProgressButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : LinearLayoutCompat(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener {
private val textViewTitle = TextView(context)
private val textViewSubtitle = TextView(context)
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private var progress = 0f
private var colorBase = context.getThemeColor(materialR.attr.colorPrimaryContainer)
private var colorProgress = context.getThemeColor(materialR.attr.colorPrimary)
private var colorText = context.getThemeColor(materialR.attr.colorOnPrimaryContainer)
private var progressAnimator: ValueAnimator? = null
var title: CharSequence?
get() = textViewTitle.textAndVisible
set(value) {
textViewTitle.textAndVisible = value
}
var subtitle: CharSequence?
get() = textViewSubtitle.textAndVisible
set(value) {
textViewSubtitle.textAndVisible = value
}
init {
orientation = VERTICAL
outlineProvider = OutlineProvider()
clipToOutline = true
context.withStyledAttributes(attrs, R.styleable.ProgressButton, defStyleAttr) {
val textAppearanceFallback = androidx.appcompat.R.style.TextAppearance_AppCompat
TextViewCompat.setTextAppearance(
textViewTitle,
getResourceId(R.styleable.ProgressButton_titleTextAppearance, textAppearanceFallback),
)
TextViewCompat.setTextAppearance(
textViewSubtitle,
getResourceId(R.styleable.ProgressButton_subtitleTextAppearance, textAppearanceFallback),
)
textViewTitle.text = getText(R.styleable.ProgressButton_title)
textViewSubtitle.text = getText(R.styleable.ProgressButton_subtitle)
colorBase = getColor(R.styleable.ProgressButton_baseColor, colorBase)
colorProgress = getColor(R.styleable.ProgressButton_progressColor, colorProgress)
colorText = getColor(R.styleable.ProgressButton_textColor, colorText)
textViewTitle.setTextColor(colorText)
textViewSubtitle.setTextColor(colorText)
progress = getInt(R.styleable.ProgressButton_android_progress, 0).toFloat() /
getInt(R.styleable.ProgressButton_android_max, 100).toFloat()
}
addView(textViewTitle, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
addView(
textViewSubtitle,
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).also { lp ->
lp.topMargin = context.resources.resolveDp(2)
},
)
paint.style = Paint.Style.FILL
paint.color = colorProgress
paint.alpha = 84 // 255 * 0.33F
applyGravity()
setWillNotDraw(false)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawColor(colorBase)
canvas.drawRect(0f, 0f, width * progress, height.toFloat(), paint)
}
override fun setGravity(gravity: Int) {
super.setGravity(gravity)
if (childCount != 0) {
applyGravity()
}
}
override fun setEnabled(enabled: Boolean) {
super.setEnabled(enabled)
children.forEach { it.isEnabled = enabled }
}
override fun onAnimationUpdate(animation: ValueAnimator) {
progress = animation.animatedValue as Float
invalidate()
}
fun setTitle(@StringRes titleResId: Int) {
textViewTitle.setTextAndVisible(titleResId)
}
fun setSubtitle(@StringRes titleResId: Int) {
textViewSubtitle.setTextAndVisible(titleResId)
}
fun setProgress(value: Float, animate: Boolean) {
progressAnimator?.cancel()
if (animate) {
progressAnimator = ValueAnimator.ofFloat(progress, value).apply {
duration = context.getAnimationDuration(android.R.integer.config_shortAnimTime)
interpolator = AccelerateDecelerateInterpolator()
addUpdateListener(this@ProgressButton)
start()
}
} else {
progressAnimator = null
progress = value
invalidate()
}
}
private fun applyGravity() {
val value = (gravity and Gravity.HORIZONTAL_GRAVITY_MASK) or Gravity.CENTER_VERTICAL
textViewTitle.gravity = value
textViewSubtitle.gravity = value
}
private class OutlineProvider : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, view.height / 2f)
}
}
}

View File

@@ -11,6 +11,7 @@ import android.view.View
import android.view.ViewOutlineProvider import android.view.ViewOutlineProvider
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.FloatRange import androidx.annotation.FloatRange
import androidx.collection.MutableFloatList
import androidx.interpolator.view.animation.FastOutSlowInInterpolator import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
@@ -25,7 +26,7 @@ class SegmentedBarView @JvmOverloads constructor(
private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val segmentsData = ArrayList<Segment>() private val segmentsData = ArrayList<Segment>()
private val segmentsSizes = ArrayList<Float>() private val segmentsSizes = MutableFloatList()
private var cornerSize = 0f private var cornerSize = 0f
private var scaleFactor = 1f private var scaleFactor = 1f
private var scaleAnimator: ValueAnimator? = null private var scaleAnimator: ValueAnimator? = null

View File

@@ -75,7 +75,7 @@ class TipView @JvmOverloads constructor(
val shapeAppearanceModel = ShapeAppearanceModel.builder(context, attrs, defStyleAttr, 0).build() val shapeAppearanceModel = ShapeAppearanceModel.builder(context, attrs, defStyleAttr, 0).build()
background = MaterialShapeDrawable(shapeAppearanceModel).also { background = MaterialShapeDrawable(shapeAppearanceModel).also {
it.fillColor = getColorStateList(R.styleable.TipView_cardBackgroundColor) it.fillColor = getColorStateList(R.styleable.TipView_cardBackgroundColor)
?: context.getThemeColorStateList(R.attr.m3ColorExploreButton) ?: context.getThemeColorStateList(com.google.android.material.R.attr.colorSurfaceContainerHigh)
it.strokeWidth = getDimension(R.styleable.TipView_strokeWidth, 0f) it.strokeWidth = getDimension(R.styleable.TipView_strokeWidth, 0f)
it.strokeColor = getColorStateList(R.styleable.TipView_strokeColor) it.strokeColor = getColorStateList(R.styleable.TipView_strokeColor)
it.elevation = getDimension(R.styleable.TipView_elevation, 0f) it.elevation = getDimension(R.styleable.TipView_elevation, 0f)

View File

@@ -1,70 +0,0 @@
package org.koitharu.kotatsu.core.util
import android.content.Context
import android.view.GestureDetector
import android.view.MotionEvent
import kotlin.math.roundToInt
class GridTouchHelper(
context: Context,
private val listener: OnGridTouchListener,
) : GestureDetector.SimpleOnGestureListener() {
private val detector = GestureDetector(context, this)
private val width = context.resources.displayMetrics.widthPixels
private val height = context.resources.displayMetrics.heightPixels
private var isDispatching = false
init {
detector.setIsLongpressEnabled(true)
detector.setOnDoubleTapListener(this)
}
fun dispatchTouchEvent(event: MotionEvent) {
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
isDispatching = listener.onProcessTouch(event.rawX.toInt(), event.rawY.toInt())
}
detector.onTouchEvent(event)
}
override fun onSingleTapConfirmed(event: MotionEvent): Boolean {
if (!isDispatching) {
return true
}
val xIndex = (event.rawX * 2f / width).roundToInt()
val yIndex = (event.rawY * 2f / height).roundToInt()
listener.onGridTouch(
when (xIndex) {
0 -> AREA_LEFT
1 -> {
when (yIndex) {
0 -> AREA_TOP
1 -> AREA_CENTER
2 -> AREA_BOTTOM
else -> return false
}
}
2 -> AREA_RIGHT
else -> return false
},
)
return true
}
companion object {
const val AREA_CENTER = 1
const val AREA_LEFT = 2
const val AREA_RIGHT = 3
const val AREA_TOP = 4
const val AREA_BOTTOM = 5
}
interface OnGridTouchListener {
fun onGridTouch(area: Int)
fun onProcessTouch(rawX: Int, rawY: Int): Boolean
}
}

View File

@@ -0,0 +1,55 @@
package org.koitharu.kotatsu.core.util
import android.content.Context
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.core.graphics.ColorUtils
import com.google.android.material.R
import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.parsers.model.Manga
import kotlin.math.absoluteValue
object KotatsuColors {
@ColorInt
fun segmentColor(context: Context, @AttrRes resId: Int): Int {
val colorHex = String.format("%06x", context.getThemeColor(resId))
val hue = getHue(colorHex)
val color = ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh)
return MaterialColors.harmonize(color, backgroundColor)
}
@ColorInt
fun random(seed: Any): Int {
val hue = (seed.hashCode() % 360).absoluteValue.toFloat()
return ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
}
@ColorInt
fun ofManga(context: Context, manga: Manga?): Int {
val color = if (manga != null) {
val hue = (manga.id.absoluteValue % 360).toFloat()
ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
} else {
context.getThemeColor(R.attr.colorOutline)
}
val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh)
return MaterialColors.harmonize(color, backgroundColor)
}
private fun getHue(hex: String): Float {
val r = (hex.substring(0, 2).toInt(16)).toFloat()
val g = (hex.substring(2, 4).toInt(16)).toFloat()
val b = (hex.substring(4, 6).toInt(16)).toFloat()
var hue = 0F
if ((r >= g) && (g >= b)) {
hue = 60 * (g - b) / (r - b)
} else if ((g > r) && (r >= b)) {
hue = 60 * (2 - (r - b) / (g - b))
}
return hue
}
}

View File

@@ -14,7 +14,7 @@ import android.content.ContextWrapper
import android.content.OperationApplicationException import android.content.OperationApplicationException
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.SyncResult import android.content.SyncResult
import android.content.pm.PackageManager import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.content.pm.ResolveInfo import android.content.pm.ResolveInfo
import android.database.SQLException import android.database.SQLException
import android.graphics.Bitmap import android.graphics.Bitmap
@@ -27,14 +27,19 @@ import android.provider.Settings
import android.view.View import android.view.View
import android.view.ViewPropertyAnimator import android.view.ViewPropertyAnimator
import android.view.Window import android.view.Window
import android.widget.Toast import android.webkit.WebView
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.IntegerRes import androidx.annotation.IntegerRes
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDialog
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope import androidx.lifecycle.coroutineScope
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
@@ -136,7 +141,7 @@ fun Window.setNavigationBarTransparentCompat(context: Context, elevation: Float,
} else { } else {
// Set navbar scrim 70% of navigationBarColor // Set navbar scrim 70% of navigationBarColor
ElevationOverlayProvider(context).compositeOverlayIfNeeded( ElevationOverlayProvider(context).compositeOverlayIfNeeded(
context.getThemeColor(R.attr.m3ColorBottomMenuBackground, alphaFactor), context.getThemeColor(com.google.android.material.R.attr.colorSurfaceContainer, alphaFactor),
elevation, elevation,
) )
} }
@@ -216,25 +221,26 @@ fun Context.findActivity(): Activity? = when (this) {
else -> null else -> null
} }
inline fun Activity.catchingWebViewUnavailability(block: () -> Unit): Boolean { fun Fragment.findAppCompatDelegate(): AppCompatDelegate? {
return try { ((this as? DialogFragment)?.dialog as? AppCompatDialog)?.run {
block() return delegate
true
} catch (e: Exception) {
if (e.isWebViewUnavailable()) {
Toast.makeText(this, R.string.web_view_unavailable, Toast.LENGTH_LONG).show()
finishAfterTransition()
false
} else {
throw e
}
} }
return parentFragment?.findAppCompatDelegate() ?: (activity as? AppCompatActivity)?.delegate
} }
fun Context.checkNotificationPermission(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { fun Context.checkNotificationPermission(channelId: String?): Boolean {
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED val hasPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
} else { ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PERMISSION_GRANTED
NotificationManagerCompat.from(this).areNotificationsEnabled() } else {
NotificationManagerCompat.from(this).areNotificationsEnabled()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && hasPermission && channelId != null) {
val channel = NotificationManagerCompat.from(this).getNotificationChannel(channelId)
if (channel != null && channel.importance == NotificationManagerCompat.IMPORTANCE_NONE) {
return false
}
}
return hasPermission
} }
@WorkerThread @WorkerThread
@@ -251,3 +257,13 @@ fun Context.ensureRamAtLeast(requiredSize: Long) {
throw IllegalStateException("Not enough free memory") throw IllegalStateException("Not enough free memory")
} }
} }
fun WebView.configureForParser(userAgentOverride: String?) = with(settings) {
javaScriptEnabled = true
domStorageEnabled = true
mediaPlaybackRequiresUserGesture = false
databaseEnabled = true
if (userAgentOverride != null) {
userAgentString = userAgentOverride
}
}

View File

@@ -24,11 +24,15 @@ inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String)
} }
inline fun <reified T : Serializable> Intent.getSerializableExtraCompat(key: String): T? { inline fun <reified T : Serializable> Intent.getSerializableExtraCompat(key: String): T? {
return getSerializableExtra(key) as T? return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
getSerializableExtra(key, T::class.java)
} else {
getSerializableExtra(key) as T?
}
} }
inline fun <reified T : Serializable> Bundle.getSerializableCompat(key: String): T? { inline fun <reified T : Serializable> Bundle.getSerializableCompat(key: String): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
getSerializable(key, T::class.java) getSerializable(key, T::class.java)
} else { } else {
getSerializable(key) as T? getSerializable(key) as T?

View File

@@ -25,7 +25,7 @@ fun ImageView.newImageRequest(lifecycleOwner: LifecycleOwner, data: Any?): Image
} }
// disposeImageRequest() // disposeImageRequest()
return ImageRequest.Builder(context) return ImageRequest.Builder(context)
.data(data) .data(data?.takeUnless { it == "" || it == 0 })
.lifecycle(lifecycleOwner) .lifecycle(lifecycleOwner)
.crossfade(context) .crossfade(context)
.target(this) .target(this)

View File

@@ -18,6 +18,7 @@ fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo
if (instant.until(Instant.now(), ChronoUnit.MINUTES) < 3) DateTimeAgo.JustNow if (instant.until(Instant.now(), ChronoUnit.MINUTES) < 3) DateTimeAgo.JustNow
else DateTimeAgo.Today else DateTimeAgo.Today
} }
diffDays == 1L -> DateTimeAgo.Yesterday diffDays == 1L -> DateTimeAgo.Yesterday
diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays.toInt()) diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays.toInt())
else -> { else -> {
@@ -30,3 +31,5 @@ fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo
} }
} }
} }
fun Long.toInstantOrNull() = if (this == 0L) null else Instant.ofEpochMilli(this)

View File

@@ -10,7 +10,6 @@ import android.provider.OpenableColumns
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.fs.FileSequence import org.koitharu.kotatsu.core.fs.FileSequence
import java.io.File import java.io.File
@@ -19,6 +18,7 @@ import java.nio.file.attribute.BasicFileAttributes
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
import kotlin.io.path.ExperimentalPathApi import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.PathWalkOption
import kotlin.io.path.readAttributes import kotlin.io.path.readAttributes
import kotlin.io.path.walk import kotlin.io.path.walk
@@ -52,7 +52,7 @@ fun File.getStorageName(context: Context): String = runCatching {
fun Uri.toFileOrNull() = if (scheme == URI_SCHEME_FILE) path?.let(::File) else null fun Uri.toFileOrNull() = if (scheme == URI_SCHEME_FILE) path?.let(::File) else null
suspend fun File.deleteAwait() = withContext(Dispatchers.IO) { suspend fun File.deleteAwait() = runInterruptible(Dispatchers.IO) {
delete() || deleteRecursively() delete() || deleteRecursively()
} }
@@ -72,7 +72,7 @@ fun ContentResolver.resolveName(uri: Uri): String? {
} }
suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) { suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) {
walkCompat().sumOf { it.length() } walkCompat(includeDirectories = false).sumOf { it.length() }
} }
fun File.children() = FileSequence(this) fun File.children() = FileSequence(this)
@@ -87,10 +87,16 @@ val File.creationTime
} }
@OptIn(ExperimentalPathApi::class) @OptIn(ExperimentalPathApi::class)
fun File.walkCompat() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { fun File.walkCompat(includeDirectories: Boolean): Sequence<File> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Use lazy loading on Android 8.0 and later // Use lazy loading on Android 8.0 and later
toPath().walk().map { it.toFile() } val walk = if (includeDirectories) {
toPath().walk(PathWalkOption.INCLUDE_DIRECTORIES)
} else {
toPath().walk()
}
walk.map { it.toFile() }
} else { } else {
// Directories are excluded by default in Path.walk(), so do it here as well // Directories are excluded by default in Path.walk(), so do it here as well
walk().filter { it.isFile } val walk = walk()
if (includeDirectories) walk else walk.filter { it.isFile }
} }

View File

@@ -11,8 +11,10 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import java.util.concurrent.atomic.AtomicInteger
fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> { fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
var isFirstCall = true var isFirstCall = true
@@ -37,6 +39,14 @@ fun <T> Flow<T>.onEachWhile(action: suspend (T) -> Boolean): Flow<T> {
} }
} }
fun <T> Flow<T>.onEachIndexed(action: suspend (index: Int, T) -> Unit): Flow<T> {
val counter = AtomicInteger(0)
return transform { value ->
action(counter.getAndIncrement(), value)
return@transform emit(value)
}
}
inline fun <T, R> Flow<List<T>>.mapItems(crossinline transform: (T) -> R): Flow<List<R>> { inline fun <T, R> Flow<List<T>>.mapItems(crossinline transform: (T) -> R): Flow<List<R>> {
return map { list -> list.map(transform) } return map { list -> list.map(transform) }
} }

View File

@@ -60,3 +60,25 @@ fun DialogFragment.showDistinct(fm: FragmentManager, tag: String) {
} }
show(fm, tag) show(fm, tag)
} }
tailrec fun Fragment.dismissParentDialog(): Boolean {
return when (val parent = parentFragment) {
null -> return false
is DialogFragment -> {
parent.dismiss()
true
}
else -> parent.dismissParentDialog()
}
}
@Suppress("UNCHECKED_CAST")
tailrec fun <T> Fragment.findParentCallback(cls: Class<T>): T? {
val parent = parentFragment
return when {
parent == null -> cls.castOrNull(activity)
cls.isInstance(parent) -> parent as T
else -> parent.findParentCallback(cls)
}
}

View File

@@ -1,11 +1,13 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import okhttp3.Cookie import okhttp3.Cookie
import okhttp3.Headers
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import okhttp3.internal.isSensitiveHeader
import okio.IOException import okio.IOException
import org.json.JSONObject import org.json.JSONObject
import org.jsoup.HttpStatusException import org.jsoup.HttpStatusException
@@ -59,3 +61,16 @@ fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
c.httpOnly() c.httpOnly()
} }
} }
fun String.sanitizeHeaderValue(): String {
return if (all(Char::isValidForHeaderValue)) {
this // fast path
} else {
filter(Char::isValidForHeaderValue)
}
}
private fun Char.isValidForHeaderValue(): Boolean {
// from okhttp3.Headers$Companion.checkValue
return this == '\t' || this in '\u0020'..'\u007e'
}

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.core.util.ext
inline fun Int.ifZero(defaultValue: () -> Int): Int = if (this == 0) defaultValue() else this inline fun Int.ifZero(defaultValue: () -> Int): Int = if (this == 0) defaultValue() else this
inline fun Long.ifZero(defaultValue: () -> Long): Long = if (this == 0L) defaultValue() else this
fun longOf(a: Int, b: Int): Long { fun longOf(a: Int, b: Int): Long {
return a.toLong() shl 32 or (b.toLong() and 0xffffffffL) return a.toLong() shl 32 or (b.toLong() and 0xffffffffL)
} }

View File

@@ -2,22 +2,23 @@ package org.koitharu.kotatsu.core.util.ext
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.res.Resources import android.content.res.Resources
import android.util.AndroidRuntimeException
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.collection.arraySetOf import androidx.collection.arraySetOf
import coil.network.HttpException import coil.network.HttpException
import okio.FileNotFoundException import okio.FileNotFoundException
import okio.IOException import okio.IOException
import org.acra.ktx.sendWithAcra import org.acra.ktx.sendWithAcra
import org.json.JSONException
import org.jsoup.HttpStatusException import org.jsoup.HttpStatusException
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
import org.koitharu.kotatsu.core.exceptions.CaughtException import org.koitharu.kotatsu.core.exceptions.CaughtException
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.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
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED
@@ -43,6 +44,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is TooManyRequestExceptions -> resources.getString(R.string.too_many_requests_message) is TooManyRequestExceptions -> resources.getString(R.string.too_many_requests_message)
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported) is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
is FileNotFoundException -> resources.getString(R.string.file_not_found) is FileNotFoundException -> resources.getString(R.string.file_not_found)
is AccessDeniedException -> resources.getString(R.string.no_access_to_file) is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
is EmptyHistoryException -> resources.getString(R.string.history_is_empty) is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
@@ -55,8 +57,11 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is SocketTimeoutException, is SocketTimeoutException,
-> resources.getString(R.string.network_error) -> resources.getString(R.string.network_error)
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
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 HttpException -> getHttpDisplayMessage(response.code, resources) is HttpException -> getHttpDisplayMessage(response.code, resources)
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources) is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
@@ -99,24 +104,27 @@ fun Throwable.isReportable(): Boolean {
return this is Error || this.javaClass in reportableExceptions return this is Error || this.javaClass in reportableExceptions
} }
fun Throwable.isNetworkError(): Boolean {
return this is UnknownHostException || this is SocketTimeoutException
}
fun Throwable.report() { fun Throwable.report() {
val exception = CaughtException(this, "${javaClass.simpleName}($message)") val exception = CaughtException(this, "${javaClass.simpleName}($message)")
exception.sendWithAcra() exception.sendWithAcra()
} }
private val reportableExceptions = arraySetOf<Class<*>>( private val reportableExceptions = arraySetOf<Class<*>>(
ParseException::class.java,
JSONException::class.java,
RuntimeException::class.java, RuntimeException::class.java,
IllegalStateException::class.java, IllegalStateException::class.java,
IllegalArgumentException::class.java, IllegalArgumentException::class.java,
ConcurrentModificationException::class.java, ConcurrentModificationException::class.java,
UnsupportedOperationException::class.java, UnsupportedOperationException::class.java,
NoDataReceivedException::class.java,
) )
fun Throwable.isWebViewUnavailable(): Boolean { fun Throwable.isWebViewUnavailable(): Boolean {
return (this is AndroidRuntimeException && message?.contains("WebView") == true) || val trace = stackTraceToString()
cause?.isWebViewUnavailable() == true return trace.contains("android.webkit.WebView.<init>")
} }
@Suppress("FunctionName") @Suppress("FunctionName")

View File

@@ -1,15 +1,14 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import android.app.Activity
import android.graphics.Rect import android.graphics.Rect
import android.os.Build import android.os.Build
import android.view.View import android.view.View
import android.view.View.MeasureSpec import android.view.View.MeasureSpec
import android.view.ViewGroup import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.Checkable import android.widget.Checkable
import androidx.appcompat.widget.ActionMenuView import androidx.appcompat.widget.ActionMenuView
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.view.SoftwareKeyboardControllerCompat
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.descendants import androidx.core.view.descendants
import androidx.core.view.isVisible import androidx.core.view.isVisible
@@ -18,19 +17,18 @@ import androidx.recyclerview.widget.RecyclerView.ViewHolder
import androidx.swiperefreshlayout.widget.CircularProgressDrawable import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import com.google.android.material.chip.Chip
import com.google.android.material.progressindicator.BaseProgressIndicator import com.google.android.material.progressindicator.BaseProgressIndicator
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import kotlin.math.roundToInt import kotlin.math.roundToInt
fun View.hideKeyboard() { fun View.hideKeyboard() {
val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager SoftwareKeyboardControllerCompat(this).hide()
imm.hideSoftInputFromWindow(this.windowToken, 0)
} }
fun View.showKeyboard() { fun View.showKeyboard() {
val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager SoftwareKeyboardControllerCompat(this).show()
imm.showSoftInput(this, 0)
} }
fun View.hasGlobalPoint(x: Int, y: Int): Boolean { fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
@@ -172,3 +170,11 @@ fun MaterialButton.setProgressIcon() {
icon = progressDrawable icon = progressDrawable
progressDrawable.start() progressDrawable.start()
} }
fun Chip.setProgressIcon() {
val progressDrawable = CircularProgressDrawable(context)
progressDrawable.strokeWidth = resources.resolveDp(2f)
progressDrawable.setColorSchemeColors(currentTextColor)
chipIcon = progressDrawable
progressDrawable.start()
}

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.core.util.progress package org.koitharu.kotatsu.core.util.progress
import android.os.SystemClock import android.os.SystemClock
import androidx.collection.IntList
import androidx.collection.MutableIntList
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.roundToLong import kotlin.math.roundToLong
@@ -10,7 +12,7 @@ private const val NO_TIME = -1L
class TimeLeftEstimator { class TimeLeftEstimator {
private var times = ArrayList<Int>() private var times = MutableIntList()
private var lastTick: Tick? = null private var lastTick: Tick? = null
private val tooLargeTime = TimeUnit.DAYS.toMillis(1) private val tooLargeTime = TimeUnit.DAYS.toMillis(1)
@@ -50,6 +52,15 @@ class TimeLeftEstimator {
return if (etl == NO_TIME) NO_TIME else System.currentTimeMillis() + etl return if (etl == NO_TIME) NO_TIME else System.currentTimeMillis() + etl
} }
private fun IntList.average(): Double {
if (isEmpty()) {
return 0.0
}
var acc = 0L
forEach { acc += it }
return acc / size.toDouble()
}
private class Tick( private class Tick(
@JvmField val value: Int, @JvmField val value: Int,
@JvmField val total: Int, @JvmField val total: Int,

View File

@@ -0,0 +1,29 @@
package org.koitharu.kotatsu.details.data
import android.content.res.Resources
import org.koitharu.kotatsu.R
data class ReadingTime(
val minutes: Int,
val hours: Int,
val isContinue: Boolean,
) {
fun format(resources: Resources): String = when {
hours == 0 && minutes == 0 -> resources.getString(R.string.less_than_minute)
hours == 0 -> resources.getQuantityString(R.plurals.minutes, minutes, minutes)
minutes == 0 -> resources.getQuantityString(R.plurals.hours, hours, hours)
else -> resources.getString(
R.string.remaining_time_pattern,
resources.getQuantityString(R.plurals.hours, hours, hours),
resources.getQuantityString(R.plurals.minutes, minutes, minutes),
)
}
fun formatShort(resources: Resources): String? = when {
hours == 0 && minutes == 0 -> null
hours == 0 -> resources.getString(R.string.minutes_short, minutes)
minutes == 0 -> resources.getString(R.string.hours_short, hours)
else -> resources.getString(R.string.hours_minutes_short, hours, minutes)
}
}

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.data.MangaDetails
@@ -35,6 +36,10 @@ class DetailsInteractor @Inject constructor(
.map { it.isNotEmpty() } .map { it.isNotEmpty() }
} }
fun observeFavourite(mangaId: Long): Flow<Set<FavouriteCategory>> {
return favouritesRepository.observeCategories(mangaId)
}
fun observeNewChapters(mangaId: Long): Flow<Int> { fun observeNewChapters(mangaId: Long): Flow<Int> {
return settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled } return settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled }
.flatMapLatest { isEnabled -> .flatMapLatest { isEnabled ->

View File

@@ -54,7 +54,8 @@ class DetailsLoadUseCase @Inject constructor(
} 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), true))
} ?: throw e }
throw e
} }
} }

View File

@@ -0,0 +1,53 @@
package org.koitharu.kotatsu.details.domain
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.data.ReadingTime
import org.koitharu.kotatsu.stats.data.StatsRepository
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlin.math.roundToInt
class ReadingTimeUseCase @Inject constructor(
private val settings: AppSettings,
private val statsRepository: StatsRepository,
) {
suspend fun invoke(manga: MangaDetails?, branch: String?, history: MangaHistory?): ReadingTime? {
if (!settings.isReadingTimeEstimationEnabled) {
return null
}
val chapters = manga?.chapters?.get(branch)
if (chapters.isNullOrEmpty()) {
return null
}
val isOnHistoryBranch = history != null && chapters.findById(history.chapterId) != null
// Impossible task, I guess. Good luck on this.
var averageTimeSec: Int = 20 /* pages */ * getSecondsPerPage(manga.id) * chapters.size
if (isOnHistoryBranch) {
averageTimeSec = (averageTimeSec * (1f - checkNotNull(history).percent)).roundToInt()
}
if (averageTimeSec < 60) {
return null
}
return ReadingTime(
minutes = (averageTimeSec / 60) % 60,
hours = averageTimeSec / 3600,
isContinue = isOnHistoryBranch,
)
}
private suspend fun getSecondsPerPage(mangaId: Long): Int {
var time = if (settings.isStatsEnabled) {
TimeUnit.MILLISECONDS.toSeconds(statsRepository.getTimePerPage(mangaId)).toInt()
} else {
0
}
if (time == 0) {
time = 10 // default
}
return time
}
}

View File

@@ -0,0 +1,126 @@
package org.koitharu.kotatsu.details.ui
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.widget.SearchView
import androidx.core.view.MenuProvider
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.slider.LabelFormatter
import com.google.android.material.slider.Slider
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_CHAPTERS
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_PAGES
import java.lang.ref.WeakReference
class ChapterPagesMenuProvider(
private val viewModel: DetailsViewModel,
private val sheet: BaseAdaptiveSheet<*>,
private val pager: ViewPager2,
private val settings: AppSettings,
) : OnBackPressedCallback(false), MenuProvider, SearchView.OnQueryTextListener, MenuItem.OnActionExpandListener,
Slider.OnChangeListener {
private var expandedItemRef: WeakReference<MenuItem>? = null
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
val tab = getCurrentTab()
when (tab) {
TAB_CHAPTERS -> {
menuInflater.inflate(R.menu.opt_chapters, menu)
menu.findItem(R.id.action_search)?.run {
setOnActionExpandListener(this@ChapterPagesMenuProvider)
(actionView as? SearchView)?.setupChaptersSearchView()
}
menu.findItem(R.id.action_search)?.isVisible = viewModel.isChaptersEmpty.value == false
menu.findItem(R.id.action_reversed)?.isChecked = viewModel.isChaptersReversed.value == true
menu.findItem(R.id.action_grid_view)?.isChecked = viewModel.isChaptersInGridView.value == true
}
TAB_PAGES -> {
menuInflater.inflate(R.menu.opt_pages, menu)
menu.findItem(R.id.action_grid_size)?.run {
setOnActionExpandListener(this@ChapterPagesMenuProvider)
(actionView as? Slider)?.setupPagesSizeSlider()
}
}
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_reversed -> {
viewModel.setChaptersReversed(!menuItem.isChecked)
true
}
R.id.action_grid_view -> {
viewModel.setChaptersInGridView(!menuItem.isChecked)
true
}
else -> false
}
override fun handleOnBackPressed() {
expandedItemRef?.get()?.collapseActionView()
}
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
expandedItemRef = WeakReference(item)
sheet.expandAndLock()
isEnabled = true
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
expandedItemRef = null
isEnabled = false
(item.actionView as? SearchView)?.setQuery("", false)
viewModel.performChapterSearch(null)
sheet.unlock()
return true
}
override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.performChapterSearch(newText)
return true
}
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
if (fromUser) {
settings.gridSizePages = value.toInt()
}
}
private fun SearchView.setupChaptersSearchView() {
setOnQueryTextListener(this@ChapterPagesMenuProvider)
setIconifiedByDefault(false)
queryHint = context.getString(R.string.search_chapters)
}
private fun Slider.setupPagesSizeSlider() {
valueFrom = 50f
valueTo = 150f
stepSize = 5f
isTickVisible = false
labelBehavior = LabelFormatter.LABEL_FLOATING
setLabelFormatter(IntPercentLabelFormatter(context))
setValueRounded(settings.gridSizePages.toFloat())
addOnChangeListener(this@ChapterPagesMenuProvider)
}
private fun getCurrentTab(): Int {
var page = pager.currentItem
if (page > 0 && pager.adapter?.itemCount == 2) { // no Pages page
page++ // shift
}
return page
}
}

View File

@@ -1,10 +1,14 @@
package org.koitharu.kotatsu.details.ui package org.koitharu.kotatsu.details.ui
import android.content.Context
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.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
fun MangaDetails.mapChapters( fun MangaDetails.mapChapters(
@@ -12,6 +16,7 @@ fun MangaDetails.mapChapters(
newCount: Int, newCount: Int,
branch: String?, branch: String?,
bookmarks: List<Bookmark>, bookmarks: List<Bookmark>,
isGrid: Boolean,
): List<ChapterListItem> { ): List<ChapterListItem> {
val remoteChapters = chapters[branch].orEmpty() val remoteChapters = chapters[branch].orEmpty()
val localChapters = local?.manga?.getChapters(branch).orEmpty() val localChapters = local?.manga?.getChapters(branch).orEmpty()
@@ -43,6 +48,7 @@ fun MangaDetails.mapChapters(
isNew = isUnread && result.size >= newFrom, isNew = isUnread && result.size >= newFrom,
isDownloaded = local != null, isDownloaded = local != null,
isBookmarked = chapter.id in bookmarked, isBookmarked = chapter.id in bookmarked,
isGrid = isGrid,
) )
} }
if (!localMap.isNullOrEmpty()) { if (!localMap.isNullOrEmpty()) {
@@ -56,8 +62,28 @@ fun MangaDetails.mapChapters(
isNew = false, isNew = false,
isDownloaded = !isLocal, isDownloaded = !isLocal,
isBookmarked = chapter.id in bookmarked, isBookmarked = chapter.id in bookmarked,
isGrid = isGrid,
) )
} }
} }
return result return result
} }
fun List<ChapterListItem>.withVolumeHeaders(context: Context): List<ListModel> {
var prevVolume = 0
val result = ArrayList<ListModel>((size * 1.4).toInt())
for (item in this) {
val chapter = item.chapter
if (chapter.volume != prevVolume) {
val text = if (chapter.volume == 0) {
context.getString(R.string.volume_unknown)
} else {
context.getString(R.string.volume_, chapter.volume)
}
result.add(ListHeader(text))
prevVolume = chapter.volume
}
result.add(item)
}
return result
}

View File

@@ -28,8 +28,9 @@ class ChaptersMenuProvider(
} }
override fun onPrepareMenu(menu: Menu) { override fun onPrepareMenu(menu: Menu) {
menu.findItem(R.id.action_reversed)?.isChecked = viewModel.isChaptersReversed.value == true
menu.findItem(R.id.action_search)?.isVisible = viewModel.isChaptersEmpty.value == false menu.findItem(R.id.action_search)?.isVisible = viewModel.isChaptersEmpty.value == false
menu.findItem(R.id.action_reversed)?.isChecked = viewModel.isChaptersReversed.value == true
menu.findItem(R.id.action_grid_view)?.isChecked = viewModel.isChaptersInGridView.value == true
} }
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
@@ -37,6 +38,10 @@ class ChaptersMenuProvider(
viewModel.setChaptersReversed(!menuItem.isChecked) viewModel.setChaptersReversed(!menuItem.isChecked)
true true
} }
R.id.action_grid_view-> {
viewModel.setChaptersInGridView(!menuItem.isChecked)
true
}
else -> false else -> false
} }

View File

@@ -4,7 +4,9 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.text.style.DynamicDrawableSpan
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import android.text.style.ImageSpan
import android.text.style.RelativeSizeSpan import android.text.style.RelativeSizeSpan
import android.transition.AutoTransition import android.transition.AutoTransition
import android.transition.Slide import android.transition.Slide
@@ -27,6 +29,7 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.preference.PreferenceManager
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
@@ -34,7 +37,6 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
@@ -123,20 +125,11 @@ class DetailsActivity :
viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated) viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated)
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved) viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onError.observeEvent( viewModel.onError.observeEvent(this, DetailsErrorObserver(this, viewModel, exceptionResolver))
viewModel.onActionDone.observeEvent(
this, this,
SnackbarErrorObserver( ReversibleActionObserver(viewBinding.containerDetails, viewBinding.layoutBottom),
host = viewBinding.containerDetails,
fragment = null,
resolver = exceptionResolver,
onResolved = { isResolved ->
if (isResolved) {
viewModel.reload()
}
},
),
) )
viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.containerDetails))
viewModel.onShowTip.observeEvent(this) { showTip() } viewModel.onShowTip.observeEvent(this) { showTip() }
viewModel.historyInfo.observe(this, ::onHistoryChanged) viewModel.historyInfo.observe(this, ::onHistoryChanged)
viewModel.selectedBranch.observe(this) { viewModel.selectedBranch.observe(this) {
@@ -148,6 +141,7 @@ class DetailsActivity :
viewModel.isChaptersEmpty.observe(this, chaptersMenuInvalidator) viewModel.isChaptersEmpty.observe(this, chaptersMenuInvalidator)
val menuInvalidator = MenuInvalidator(this) val menuInvalidator = MenuInvalidator(this)
viewModel.favouriteCategories.observe(this, menuInvalidator) viewModel.favouriteCategories.observe(this, menuInvalidator)
viewModel.isStatsAvailable.observe(this, menuInvalidator)
viewModel.remoteManga.observe(this, menuInvalidator) viewModel.remoteManga.observe(this, menuInvalidator)
viewModel.branches.observe(this) { viewModel.branches.observe(this) {
viewBinding.buttonDropdown.isVisible = it.size > 1 viewBinding.buttonDropdown.isVisible = it.size > 1
@@ -185,6 +179,9 @@ class DetailsActivity :
buttonTip = null buttonTip = null
val menu = PopupMenu(v.context, v) val menu = PopupMenu(v.context, v)
menu.inflate(R.menu.popup_read) menu.inflate(R.menu.popup_read)
menu.menu.findItem(R.id.action_forget)?.isVisible = viewModel.historyInfo.value.run {
!isIncognitoMode && history != null
}
menu.setOnMenuItemClickListener(this) menu.setOnMenuItemClickListener(this)
menu.setForceShowIcon(true) menu.setForceShowIcon(true)
menu.show() menu.show()
@@ -201,6 +198,11 @@ class DetailsActivity :
true true
} }
R.id.action_forget -> {
viewModel.removeFromHistory()
true
}
R.id.action_pages_thumbs -> { R.id.action_pages_thumbs -> {
val history = viewModel.historyInfo.value.history val history = viewModel.historyInfo.value.history
PagesThumbnailsSheet.show( PagesThumbnailsSheet.show(
@@ -318,6 +320,18 @@ class DetailsActivity :
val branches = viewModel.branches.value val branches = viewModel.branches.value
for ((i, branch) in branches.withIndex()) { for ((i, branch) in branches.withIndex()) {
val title = buildSpannedString { val title = buildSpannedString {
if (branch.isCurrent) {
inSpans(
ImageSpan(
this@DetailsActivity,
R.drawable.ic_current_chapter,
DynamicDrawableSpan.ALIGN_BASELINE,
),
) {
append(' ')
}
append(' ')
}
append(branch.name ?: getString(R.string.system_default)) append(branch.name ?: getString(R.string.system_default))
append(' ') append(' ')
append(' ') append(' ')
@@ -363,12 +377,13 @@ class DetailsActivity :
} }
private fun initPager() { private fun initPager() {
val adapter = DetailsPagerAdapter(this) val adapter = DetailsPagerAdapter(this, settings)
viewBinding.pager.recyclerView?.isNestedScrollingEnabled = false viewBinding.pager.recyclerView?.isNestedScrollingEnabled = false
viewBinding.pager.offscreenPageLimit = 1 viewBinding.pager.offscreenPageLimit = 1
viewBinding.pager.adapter = adapter viewBinding.pager.adapter = adapter
TabLayoutMediator(viewBinding.tabs, viewBinding.pager, adapter).attach() TabLayoutMediator(viewBinding.tabs, viewBinding.pager, adapter).attach()
viewBinding.pager.setCurrentItem(settings.defaultDetailsTab, false) viewBinding.pager.setCurrentItem(settings.defaultDetailsTab, false)
viewBinding.tabs.isVisible = adapter.itemCount > 1
} }
private fun showBottomSheet(isVisible: Boolean) { private fun showBottomSheet(isVisible: Boolean) {
@@ -408,15 +423,25 @@ class DetailsActivity :
companion object { companion object {
const val TIP_BUTTON = "btn_read" const val TIP_BUTTON = "btn_read"
private const val KEY_NEW_ACTIVITY = "new_details_screen"
fun newIntent(context: Context, manga: Manga): Intent { fun newIntent(context: Context, manga: Manga): Intent {
return Intent(context, DetailsActivity::class.java) return getActivityIntent(context)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga)) .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
} }
fun newIntent(context: Context, mangaId: Long): Intent { fun newIntent(context: Context, mangaId: Long): Intent {
return Intent(context, DetailsActivity::class.java) return getActivityIntent(context)
.putExtra(MangaIntent.KEY_ID, mangaId) .putExtra(MangaIntent.KEY_ID, mangaId)
} }
private fun getActivityIntent(context: Context): Intent {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val useNewActivity = prefs.getBoolean(KEY_NEW_ACTIVITY, false)
return Intent(
context,
if (useNewActivity) DetailsActivity2::class.java else DetailsActivity::class.java,
)
}
} }
} }

View File

@@ -0,0 +1,703 @@
package org.koitharu.kotatsu.details.ui
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.text.style.DynamicDrawableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.ImageSpan
import android.text.style.RelativeSizeSpan
import android.transition.TransitionManager
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewTreeObserver
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import coil.ImageLoader
import coil.request.ImageRequest
import coil.request.SuccessResult
import coil.transform.CircleCropTransformation
import coil.util.CoilUtils
import com.google.android.material.chip.Chip
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.iconResId
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ViewBadge
import org.koitharu.kotatsu.core.util.ext.crossfade
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.isTextTruncated
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ActivityDetailsNewBinding
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.data.ReadingTime
import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet
import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration
import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
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.util.ellipsize
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint
class DetailsActivity2 :
BaseActivity<ActivityDetailsNewBinding>(),
View.OnClickListener,
View.OnLongClickListener, PopupMenu.OnMenuItemClickListener, View.OnLayoutChangeListener,
ViewTreeObserver.OnDrawListener, ChipsView.OnChipClickListener, OnListItemClickListener<Bookmark> {
@Inject
lateinit var shortcutManager: AppShortcutManager
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var tagHighlighter: ListExtraProvider
private val viewModel: DetailsViewModel by viewModels()
var bottomSheetMediator: ChaptersBottomSheetMediator? = null
private set
private lateinit var chaptersBadge: ViewBadge
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityDetailsNewBinding.inflate(layoutInflater))
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setDisplayShowTitleEnabled(false)
}
viewBinding.buttonRead.setOnClickListener(this)
viewBinding.buttonRead.setOnLongClickListener(this)
viewBinding.buttonRead.setOnContextClickListenerCompat(this)
viewBinding.buttonChapters.setOnClickListener(this)
viewBinding.infoLayout.chipBranch.setOnClickListener(this)
viewBinding.infoLayout.chipSize.setOnClickListener(this)
viewBinding.infoLayout.chipSource.setOnClickListener(this)
viewBinding.infoLayout.chipFavorite.setOnClickListener(this)
viewBinding.infoLayout.chipAuthor.setOnClickListener(this)
viewBinding.infoLayout.chipTime.setOnClickListener(this)
viewBinding.imageViewCover.setOnClickListener(this)
viewBinding.buttonDescriptionMore.setOnClickListener(this)
viewBinding.buttonScrobblingMore.setOnClickListener(this)
viewBinding.buttonRelatedMore.setOnClickListener(this)
viewBinding.infoLayout.chipSource.setOnClickListener(this)
viewBinding.infoLayout.chipSize.setOnClickListener(this)
viewBinding.textViewDescription.addOnLayoutChangeListener(this)
viewBinding.textViewDescription.viewTreeObserver.addOnDrawListener(this)
viewBinding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
viewBinding.chipsTags.onChipClickListener = this
viewBinding.recyclerViewRelated.addItemDecoration(
SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing)),
)
TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView)
chaptersBadge = ViewBadge(viewBinding.buttonChapters, this)
viewModel.details.filterNotNull().observe(this, ::onMangaUpdated)
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onError.observeEvent(
this,
SnackbarErrorObserver(viewBinding.scrollView, null, exceptionResolver) {
if (it) viewModel.reload()
},
)
viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.scrollView, null))
viewModel.historyInfo.observe(this, ::onHistoryChanged)
viewModel.isLoading.observe(this, ::onLoadingStateChanged)
viewModel.scrobblingInfo.observe(this, ::onScrobblingInfoChanged)
viewModel.localSize.observe(this, ::onLocalSizeChanged)
viewModel.relatedManga.observe(this, ::onRelatedMangaChanged)
// viewModel.chapters.observe(this, ::onChaptersChanged)
viewModel.readingTime.observe(this, ::onReadingTimeChanged)
viewModel.selectedBranch.observe(this) {
viewBinding.infoLayout.chipBranch.text = it.ifNullOrEmpty { getString(R.string.system_default) }
}
viewModel.favouriteCategories.observe(this, ::onFavoritesChanged)
val menuInvalidator = MenuInvalidator(this)
viewModel.isStatsAvailable.observe(this, menuInvalidator)
viewModel.remoteManga.observe(this, menuInvalidator)
viewModel.branches.observe(this) {
viewBinding.infoLayout.chipBranch.isVisible = it.size > 1
}
viewModel.chapters.observe(this, PrefetchObserver(this))
viewModel.onDownloadStarted.observeEvent(
this,
DownloadStartedObserver(viewBinding.scrollView),
)
addMenuProvider(
DetailsMenuProvider(
activity = this,
viewModel = viewModel,
snackbarHost = viewBinding.scrollView,
appShortcutManager = shortcutManager,
),
)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_read -> openReader(isIncognitoMode = false)
R.id.chip_branch -> showBranchPopupMenu(v)
R.id.button_chapters -> {
ChaptersPagesSheet.show(supportFragmentManager)
}
R.id.chip_author -> {
val manga = viewModel.manga.value ?: return
startActivity(
SearchActivity.newIntent(
context = v.context,
source = manga.source,
query = manga.author ?: return,
),
)
}
R.id.chip_source -> {
val manga = viewModel.manga.value ?: return
startActivity(
MangaListActivity.newIntent(
context = v.context,
source = manga.source,
),
)
}
R.id.chip_size -> {
val manga = viewModel.manga.value ?: return
LocalInfoDialog.show(supportFragmentManager, manga)
}
R.id.chip_favorite -> {
val manga = viewModel.manga.value ?: return
FavoriteSheet.show(supportFragmentManager, manga)
}
R.id.chip_time -> {
if (viewModel.isStatsAvailable.value) {
val manga = viewModel.manga.value ?: return
MangaStatsSheet.show(supportFragmentManager, manga)
} else {
// TODO
}
}
R.id.imageView_cover -> {
val manga = viewModel.manga.value ?: return
startActivity(
ImageActivity.newIntent(
v.context,
manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl },
manga.source,
),
scaleUpActivityOptionsOf(v),
)
}
R.id.button_description_more -> {
val tv = viewBinding.textViewDescription
TransitionManager.beginDelayedTransition(tv.parentView)
if (tv.maxLines in 1 until Integer.MAX_VALUE) {
tv.maxLines = Integer.MAX_VALUE
} else {
tv.maxLines = resources.getInteger(R.integer.details_description_lines)
}
}
R.id.button_scrobbling_more -> {
val manga = viewModel.manga.value ?: return
ScrobblingSelectorSheet.show(supportFragmentManager, manga, null)
}
R.id.button_related_more -> {
val manga = viewModel.manga.value ?: return
startActivity(RelatedMangaActivity.newIntent(v.context, manga))
}
}
}
override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag ?: return
startActivity(MangaListActivity.newIntent(this, setOf(tag)))
}
override fun onLongClick(v: View): Boolean = when (v.id) {
R.id.button_read -> {
val menu = PopupMenu(v.context, v)
menu.inflate(R.menu.popup_read)
menu.menu.findItem(R.id.action_forget)?.isVisible = viewModel.historyInfo.value.run {
!isIncognitoMode && history != null
}
menu.setOnMenuItemClickListener(this)
menu.setForceShowIcon(true)
menu.show()
true
}
else -> false
}
override fun onMenuItemClick(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_incognito -> {
openReader(isIncognitoMode = true)
true
}
R.id.action_forget -> {
viewModel.removeFromHistory()
true
}
else -> false
}
}
override fun onItemClick(item: Bookmark, view: View) {
startActivity(
IntentBuilder(view.context).bookmark(item).incognito(true).build(),
)
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
override fun onDraw() {
viewBinding.run {
buttonDescriptionMore.isVisible = textViewDescription.maxLines == Int.MAX_VALUE ||
textViewDescription.isTextTruncated
}
}
override fun onLayoutChange(
v: View?,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int
) {
with(viewBinding) {
buttonDescriptionMore.isVisible = textViewDescription.isTextTruncated
}
}
private fun onChaptersChanged(chapters: List<ChapterListItem>?) {
// TODO
}
private fun onFavoritesChanged(categories: Set<FavouriteCategory>) {
val chip = viewBinding.infoLayout.chipFavorite
chip.setChipIconResource(if (categories.isEmpty()) R.drawable.ic_heart_outline else R.drawable.ic_heart)
chip.text = if (categories.isEmpty()) {
getString(R.string.add_to_favourites)
} else {
if (categories.size == 1) {
categories.first().title.ellipsize(FAV_LABEL_LIMIT)
}
buildString(FAV_LABEL_LIMIT + 6) {
for ((i, cat) in categories.withIndex()) {
if (i == 0) {
append(cat.title.ellipsize(FAV_LABEL_LIMIT - 4))
} else if (length + cat.title.length > FAV_LABEL_LIMIT) {
append(", ")
append(getString(R.string.list_ellipsize_pattern, categories.size - i))
break
} else {
append(", ")
append(cat.title)
}
}
}
}
}
private fun onReadingTimeChanged(time: ReadingTime?) {
val chip = viewBinding.infoLayout.chipTime
chip.textAndVisible = time?.formatShort(chip.resources)
}
private fun onDescriptionChanged(description: CharSequence?) {
val tv = viewBinding.textViewDescription
if (description.isNullOrBlank()) {
tv.setText(R.string.no_description)
} else {
tv.text = description
}
}
private fun onLocalSizeChanged(size: Long) {
val chip = viewBinding.infoLayout.chipSize
if (size == 0L) {
chip.isVisible = false
} else {
chip.text = FileSize.BYTES.format(chip.context, size)
chip.isVisible = true
}
}
private fun onRelatedMangaChanged(related: List<MangaItemModel>) {
if (related.isEmpty()) {
viewBinding.groupRelated.isVisible = false
return
}
val rv = viewBinding.recyclerViewRelated
@Suppress("UNCHECKED_CAST")
val adapter = (rv.adapter as? BaseListAdapter<ListModel>) ?: BaseListAdapter<ListModel>()
.addDelegate(
ListItemType.MANGA_GRID,
mangaGridItemAD(
coil, this,
StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)),
) { item, view ->
startActivity(DetailsActivity.newIntent(view.context, item))
},
).also { rv.adapter = it }
adapter.items = related
viewBinding.groupRelated.isVisible = true
}
private fun onLoadingStateChanged(isLoading: Boolean) {
val button = viewBinding.buttonChapters
if (isLoading) {
button.setImageDrawable(
CircularProgressDrawable(this).also {
it.setStyle(CircularProgressDrawable.LARGE)
it.setColorSchemeColors(getThemeColor(materialR.attr.colorControlNormal))
it.start()
},
)
} else {
button.setImageResource(R.drawable.ic_list_sheet)
}
}
private fun onScrobblingInfoChanged(scrobblings: List<ScrobblingInfo>) {
var adapter = viewBinding.recyclerViewScrobbling.adapter as? ScrollingInfoAdapter
viewBinding.groupScrobbling.isGone = scrobblings.isEmpty()
if (adapter != null) {
adapter.items = scrobblings
} else {
adapter = ScrollingInfoAdapter(this, coil, supportFragmentManager)
adapter.items = scrobblings
viewBinding.recyclerViewScrobbling.adapter = adapter
viewBinding.recyclerViewScrobbling.addItemDecoration(ScrobblingItemDecoration())
}
}
private fun onMangaUpdated(details: MangaDetails) {
with(viewBinding) {
val manga = details.toManga()
val hasChapters = !manga.chapters.isNullOrEmpty()
// Main
loadCover(manga)
textViewTitle.text = manga.title
textViewSubtitle.textAndVisible = manga.altTitle
infoLayout.chipAuthor.textAndVisible = manga.author
if (manga.hasRating) {
ratingBar.rating = manga.rating * ratingBar.numStars
ratingBar.isVisible = true
} else {
ratingBar.isVisible = false
}
manga.state?.let { state ->
textViewState.textAndVisible = resources.getString(state.titleResId)
imageViewState.setImageResource(state.iconResId)
} ?: run {
textViewState.isVisible = false
imageViewState.isVisible = false
}
if (manga.source == MangaSource.LOCAL || manga.source == MangaSource.DUMMY) {
infoLayout.chipSource.isVisible = false
} else {
infoLayout.chipSource.text = manga.source.title
infoLayout.chipSource.isVisible = true
}
textViewNsfw.isVisible = manga.isNsfw
// Chips
bindTags(manga)
textViewDescription.text = details.description.ifNullOrEmpty { getString(R.string.no_description) }
viewBinding.infoLayout.chipSource.also { chip ->
ImageRequest.Builder(this@DetailsActivity2)
.data(manga.source.faviconUri())
.lifecycle(this@DetailsActivity2)
.crossfade(false)
.size(resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
.target(ChipIconTarget(chip))
.placeholder(R.drawable.ic_web)
.fallback(R.drawable.ic_web)
.error(R.drawable.ic_web)
.source(manga.source)
.transformations(CircleCropTransformation())
.allowRgb565(true)
.enqueueWith(coil)
}
buttonChapters.isEnabled = hasChapters
title = manga.title
buttonRead.isEnabled = hasChapters
invalidateOptionsMenu()
}
}
private fun onMangaRemoved(manga: Manga) {
Toast.makeText(
this,
getString(R.string._s_deleted_from_local_storage, manga.title),
Toast.LENGTH_SHORT,
).show()
finishAfterTransition()
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom
)
}
private fun onHistoryChanged(info: HistoryInfo) {
with(viewBinding.buttonRead) {
if (info.history != null) {
setTitle(R.string._continue)
} else {
setTitle(R.string.read)
}
}
viewBinding.buttonRead.subtitle = when {
!info.isValid -> getString(R.string.loading_)
info.currentChapter >= 0 -> getString(
R.string.chapter_d_of_d,
info.currentChapter + 1,
info.totalChapters,
)
info.totalChapters == 0 -> getString(R.string.no_chapters)
else -> resources.getQuantityString(
R.plurals.chapters,
info.totalChapters,
info.totalChapters,
)
}
viewBinding.buttonRead.setProgress(info.history?.percent?.coerceIn(0f, 1f) ?: 0f, true)
}
private fun onNewChaptersChanged(count: Int) {
chaptersBadge.counter = count
}
private fun showBranchPopupMenu(v: View) {
val menu = PopupMenu(v.context, v)
val branches = viewModel.branches.value
for ((i, branch) in branches.withIndex()) {
val title = buildSpannedString {
if (branch.isCurrent) {
inSpans(
ImageSpan(
this@DetailsActivity2,
R.drawable.ic_current_chapter,
DynamicDrawableSpan.ALIGN_BASELINE,
),
) {
append(' ')
}
append(' ')
}
append(branch.name ?: getString(R.string.system_default))
append(' ')
append(' ')
inSpans(
ForegroundColorSpan(
v.context.getThemeColor(
android.R.attr.textColorSecondary,
Color.LTGRAY,
),
),
RelativeSizeSpan(0.74f),
) {
append(branch.count.toString())
}
}
val item = menu.menu.add(R.id.group_branches, Menu.NONE, i, title)
item.isCheckable = true
item.isChecked = branch.isSelected
}
menu.menu.setGroupCheckable(R.id.group_branches, true, true)
menu.setOnMenuItemClickListener {
viewModel.setSelectedBranch(branches.getOrNull(it.order)?.name)
true
}
menu.show()
}
private fun openReader(isIncognitoMode: Boolean) {
val manga = viewModel.manga.value ?: return
val chapterId = viewModel.historyInfo.value.history?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
Snackbar.make(viewBinding.scrollView, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT)
.show()
} else {
startActivity(
IntentBuilder(this)
.manga(manga)
.branch(viewModel.selectedBranchValue)
.incognito(isIncognitoMode)
.build(),
)
if (isIncognitoMode) {
Toast.makeText(this, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
}
}
private fun bindTags(manga: Manga) {
viewBinding.chipsTags.isVisible = manga.tags.isNotEmpty()
viewBinding.chipsTags.setChips(
manga.tags.map { tag ->
ChipsView.ChipModel(
title = tag.title,
tint = tagHighlighter.getTagTint(tag),
icon = 0,
data = tag,
isCheckable = false,
isChecked = false,
)
},
)
}
private fun loadCover(manga: Manga) {
val imageUrl = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }
val lastResult = CoilUtils.result(viewBinding.imageViewCover)
if (lastResult is SuccessResult && lastResult.request.data == imageUrl) {
return
}
val request = ImageRequest.Builder(this)
.target(viewBinding.imageViewCover)
.size(CoverSizeResolver(viewBinding.imageViewCover))
.data(imageUrl)
.tag(manga.source)
.crossfade(this)
.lifecycle(this)
.placeholderMemoryCacheKey(manga.coverUrl)
val previousDrawable = lastResult?.drawable
if (previousDrawable != null) {
request.fallback(previousDrawable)
.placeholder(previousDrawable)
.error(previousDrawable)
} else {
request.fallback(R.drawable.ic_placeholder)
.placeholder(R.drawable.ic_placeholder)
.error(R.drawable.ic_error_placeholder)
}
request.enqueueWith(coil)
}
private class PrefetchObserver(
private val context: Context,
) : FlowCollector<List<ChapterListItem>?> {
private var isCalled = false
override suspend fun emit(value: List<ChapterListItem>?) {
if (value.isNullOrEmpty()) {
return
}
if (!isCalled) {
isCalled = true
val item = value.find { it.isCurrent } ?: value.first()
MangaPrefetchService.prefetchPages(context, item.chapter)
}
}
}
companion object {
private const val FAV_LABEL_LIMIT = 10
fun newIntent(context: Context, manga: Manga): Intent {
return Intent(context, DetailsActivity2::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
}
fun newIntent(context: Context, mangaId: Long): Intent {
return Intent(context, DetailsActivity2::class.java)
.putExtra(MangaIntent.KEY_ID, mangaId)
}
}
}

View File

@@ -0,0 +1,56 @@
package org.koitharu.kotatsu.details.ui
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.exceptions.resolve.ErrorObserver
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isNetworkError
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.exception.ParseException
class DetailsErrorObserver(
override val activity: DetailsActivity,
private val viewModel: DetailsViewModel,
resolver: ExceptionResolver?,
) : ErrorObserver(
activity.viewBinding.containerDetails, null, resolver,
{ isResolved ->
if (isResolved) {
viewModel.reload()
}
},
) {
override suspend fun emit(value: Throwable) {
val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
if (value is NotFoundException || value is UnsupportedSourceException) {
snackbar.duration = Snackbar.LENGTH_INDEFINITE
}
when {
canResolve(value) -> {
snackbar.setAction(ExceptionResolver.getResolveStringId(value)) {
resolve(value)
}
}
value is ParseException -> {
val fm = fragmentManager
if (fm != null) {
snackbar.setAction(R.string.details) {
ErrorDetailsDialog.show(fm, value, value.url)
}
}
}
value.isNetworkError() -> {
snackbar.setAction(R.string.try_again) {
viewModel.reload()
}
}
}
snackbar.show()
}
}

View File

@@ -48,6 +48,7 @@ import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.showOrHide import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.details.data.ReadingTime
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity
@@ -61,6 +62,7 @@ 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.MangaItemModel
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.main.ui.owners.NoModalBottomSheetOwner import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -101,6 +103,7 @@ class DetailsFragment :
binding.buttonScrobblingMore.setOnClickListener(this) binding.buttonScrobblingMore.setOnClickListener(this)
binding.buttonRelatedMore.setOnClickListener(this) binding.buttonRelatedMore.setOnClickListener(this)
binding.infoLayout.textViewSource.setOnClickListener(this) binding.infoLayout.textViewSource.setOnClickListener(this)
binding.infoLayout.textViewSize.setOnClickListener(this)
binding.textViewDescription.addOnLayoutChangeListener(this) binding.textViewDescription.addOnLayoutChangeListener(this)
binding.textViewDescription.viewTreeObserver.addOnDrawListener(this) binding.textViewDescription.viewTreeObserver.addOnDrawListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance() binding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
@@ -118,6 +121,7 @@ class DetailsFragment :
viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged) viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged)
viewModel.relatedManga.observe(viewLifecycleOwner, ::onRelatedMangaChanged) viewModel.relatedManga.observe(viewLifecycleOwner, ::onRelatedMangaChanged)
viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged) viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged)
viewModel.readingTime.observe(viewLifecycleOwner, ::onReadingTimeChanged)
} }
override fun onItemClick(item: Bookmark, view: View) { override fun onItemClick(item: Bookmark, view: View) {
@@ -185,7 +189,7 @@ class DetailsFragment :
isVisible = false isVisible = false
} }
} }
if (manga.source == MangaSource.LOCAL) { if (manga.source == MangaSource.LOCAL || manga.source == MangaSource.DUMMY) {
infoLayout.textViewSource.isVisible = false infoLayout.textViewSource.isVisible = false
} else { } else {
infoLayout.textViewSource.text = manga.source.title infoLayout.textViewSource.text = manga.source.title
@@ -211,6 +215,19 @@ class DetailsFragment :
} }
} }
private fun onReadingTimeChanged(time: ReadingTime?) {
val binding = viewBinding ?: return
if (time == null) {
binding.approximateReadTimeLayout.isVisible = false
return
}
binding.approximateReadTime.text = time.format(resources)
binding.approximateReadTimeTitle.setText(
if (time.isContinue) R.string.approximate_remaining_time else R.string.approximate_reading_time,
)
binding.approximateReadTimeLayout.isVisible = true
}
private fun onDescriptionChanged(description: CharSequence?) { private fun onDescriptionChanged(description: CharSequence?) {
val tv = requireViewBinding().textViewDescription val tv = requireViewBinding().textViewDescription
if (description.isNullOrBlank()) { if (description.isNullOrBlank()) {
@@ -309,6 +326,10 @@ class DetailsFragment :
) )
} }
R.id.textView_size -> {
LocalInfoDialog.show(parentFragmentManager, manga)
}
R.id.imageView_cover -> { R.id.imageView_cover -> {
startActivity( startActivity(
ImageActivity.newIntent( ImageActivity.newIntent(

View File

@@ -14,6 +14,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch 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.browser.BrowserActivity import org.koitharu.kotatsu.browser.BrowserActivity
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
@@ -23,6 +24,7 @@ import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
class DetailsMenuProvider( class DetailsMenuProvider(
private val activity: FragmentActivity, private val activity: FragmentActivity,
@@ -33,6 +35,7 @@ class DetailsMenuProvider(
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_details, menu) menuInflater.inflate(R.menu.opt_details, menu)
menu.findItem(R.id.action_favourite).isVisible = activity is DetailsActivity
} }
override fun onPrepareMenu(menu: Menu) { override fun onPrepareMenu(menu: Menu) {
@@ -40,11 +43,13 @@ class DetailsMenuProvider(
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 != MangaSource.LOCAL
menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL
menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_alternatives).isVisible = manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity) menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null
menu.findItem(R.id.action_stats).isVisible = viewModel.isStatsAvailable.value
menu.findItem(R.id.action_favourite).setIcon( menu.findItem(R.id.action_favourite).setIcon(
if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline, if (viewModel.favouriteCategories.value.isNotEmpty()) R.drawable.ic_heart else R.drawable.ic_heart_outline,
) )
} }
@@ -85,7 +90,7 @@ class DetailsMenuProvider(
R.id.action_browser -> { R.id.action_browser -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
activity.startActivity(BrowserActivity.newIntent(activity, it.publicUrl, it.title)) activity.startActivity(BrowserActivity.newIntent(activity, it.publicUrl, it.source, it.title))
} }
} }
@@ -101,6 +106,18 @@ class DetailsMenuProvider(
} }
} }
R.id.action_alternatives -> {
viewModel.manga.value?.let {
activity.startActivity(AlternativesActivity.newIntent(activity, it))
}
}
R.id.action_stats -> {
viewModel.manga.value?.let {
MangaStatsSheet.show(activity.supportFragmentManager, it)
}
}
R.id.action_scrobbling -> { R.id.action_scrobbling -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
ScrobblingSelectorSheet.show(activity.supportFragmentManager, it, null) ScrobblingSelectorSheet.show(activity.supportFragmentManager, it, null)

View File

@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
@@ -25,6 +26,7 @@ import okio.FileNotFoundException
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.getPreferredBranch import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -34,6 +36,7 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.combine
import org.koitharu.kotatsu.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.onEachWhile import org.koitharu.kotatsu.core.util.ext.onEachWhile
import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.core.util.ext.requireValue
@@ -42,6 +45,7 @@ import org.koitharu.kotatsu.details.domain.BranchComparator
import org.koitharu.kotatsu.details.domain.DetailsInteractor import org.koitharu.kotatsu.details.domain.DetailsInteractor
import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
import org.koitharu.kotatsu.details.domain.ReadingTimeUseCase
import org.koitharu.kotatsu.details.domain.RelatedMangaUseCase import org.koitharu.kotatsu.details.domain.RelatedMangaUseCase
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.model.HistoryInfo
@@ -59,6 +63,7 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.stats.data.StatsRepository
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -76,6 +81,8 @@ class DetailsViewModel @Inject constructor(
private val extraProvider: ListExtraProvider, private val extraProvider: ListExtraProvider,
private val detailsLoadUseCase: DetailsLoadUseCase, private val detailsLoadUseCase: DetailsLoadUseCase,
private val progressUpdateUseCase: ProgressUpdateUseCase, private val progressUpdateUseCase: ProgressUpdateUseCase,
private val readingTimeUseCase: ReadingTimeUseCase,
private val statsRepository: StatsRepository,
) : BaseViewModel() { ) : BaseViewModel() {
private val intent = MangaIntent(savedStateHandle) private val intent = MangaIntent(savedStateHandle)
@@ -94,7 +101,10 @@ class DetailsViewModel @Inject constructor(
val history = historyRepository.observeOne(mangaId) val history = historyRepository.observeOne(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val favouriteCategories = interactor.observeIsFavourite(mangaId) val favouriteCategories = interactor.observeFavourite(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptySet())
val isStatsAvailable = statsRepository.observeHasStats(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
val remoteManga = MutableStateFlow<Manga?>(null) val remoteManga = MutableStateFlow<Manga?>(null)
@@ -113,7 +123,13 @@ class DetailsViewModel @Inject constructor(
val isChaptersReversed = settings.observeAsStateFlow( val isChaptersReversed = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default, scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_REVERSE_CHAPTERS, key = AppSettings.KEY_REVERSE_CHAPTERS,
valueProducer = { chaptersReverse }, valueProducer = { isChaptersReverse },
)
val isChaptersInGridView = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_GRID_VIEW_CHAPTERS,
valueProducer = { isChaptersGridView },
) )
val historyInfo: StateFlow<HistoryInfo> = combine( val historyInfo: StateFlow<HistoryInfo> = combine(
@@ -136,6 +152,7 @@ class DetailsViewModel @Inject constructor(
val localSize = details val localSize = details
.map { it?.local } .map { it?.local }
.distinctUntilChanged() .distinctUntilChanged()
.combine(localStorageChanges.onStart { emit(null) }) { x, _ -> x }
.map { local -> .map { local ->
if (local != null) { if (local != null) {
runCatchingCancellable { runCatchingCancellable {
@@ -169,10 +186,21 @@ class DetailsViewModel @Inject constructor(
val branches: StateFlow<List<MangaBranch>> = combine( val branches: StateFlow<List<MangaBranch>> = combine(
details, details,
selectedBranch, selectedBranch,
) { m, b -> history,
(m?.chapters ?: return@combine emptyList()) ) { m, b, h ->
.map { x -> MangaBranch(x.key, x.value.size, x.key == b) } val c = m?.chapters
.sortedWith(BranchComparator()) if (c.isNullOrEmpty()) {
return@combine emptyList()
}
val currentBranch = h?.let { m.allChapters.findById(it.chapterId) }?.branch
c.map { x ->
MangaBranch(
name = x.key,
count = x.value.size,
isSelected = x.key == b,
isCurrent = h != null && x.key == currentBranch,
)
}.sortedWith(BranchComparator())
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val isChaptersEmpty: StateFlow<Boolean> = details.map { val isChaptersEmpty: StateFlow<Boolean> = details.map {
@@ -186,12 +214,14 @@ class DetailsViewModel @Inject constructor(
selectedBranch, selectedBranch,
newChaptersCount, newChaptersCount,
bookmarks, bookmarks,
) { manga, history, branch, news, bookmarks -> isChaptersInGridView,
) { manga, history, branch, news, bookmarks, grid ->
manga?.mapChapters( manga?.mapChapters(
history, history,
news, news,
branch, branch,
bookmarks, bookmarks,
grid,
).orEmpty() ).orEmpty()
}, },
isChaptersReversed, isChaptersReversed,
@@ -200,6 +230,14 @@ class DetailsViewModel @Inject constructor(
(if (reversed) list.asReversed() else list).filterSearch(query) (if (reversed) list.asReversed() else list).filterSearch(query)
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val readingTime = combine(
details,
selectedBranch,
history,
) { m, b, h ->
readingTimeUseCase.invoke(m, b, h)
}.stateIn(viewModelScope, SharingStarted.Lazily, null)
val selectedBranchValue: String? val selectedBranchValue: String?
get() = selectedBranch.value get() = selectedBranch.value
@@ -253,7 +291,11 @@ class DetailsViewModel @Inject constructor(
} }
fun setChaptersReversed(newValue: Boolean) { fun setChaptersReversed(newValue: Boolean) {
settings.chaptersReverse = newValue settings.isChaptersReverse = newValue
}
fun setChaptersInGridView(newValue: Boolean) {
settings.isChaptersGridView = newValue
} }
fun setSelectedBranch(branch: String?) { fun setSelectedBranch(branch: String?) {
@@ -298,6 +340,7 @@ class DetailsViewModel @Inject constructor(
page = 0, page = 0,
scroll = 0, scroll = 0,
percent = percent, percent = percent,
force = true,
) )
} }
} }
@@ -324,6 +367,13 @@ class DetailsViewModel @Inject constructor(
settings.closeTip(DetailsActivity.TIP_BUTTON) settings.closeTip(DetailsActivity.TIP_BUTTON)
} }
fun removeFromHistory() {
launchJob(Dispatchers.Default) {
val handle = historyRepository.delete(setOf(mangaId))
onActionDone.call(ReversibleAction(R.string.removed_from_history, handle))
}
}
private fun doLoad() = launchLoadingJob(Dispatchers.Default) { private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
detailsLoadUseCase.invoke(intent) detailsLoadUseCase.invoke(intent)
.onEachWhile { .onEachWhile {

View File

@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.download.ui.dialog.downloadOptionAD import org.koitharu.kotatsu.download.ui.dialog.downloadOptionAD
import org.koitharu.kotatsu.settings.SettingsActivity
class DownloadDialogHelper( class DownloadDialogHelper(
private val host: View, private val host: View,
@@ -57,6 +58,9 @@ class DownloadDialogHelper(
.setCancelable(true) .setCancelable(true)
.setTitle(R.string.download) .setTitle(R.string.download)
.setNegativeButton(android.R.string.cancel) .setNegativeButton(android.R.string.cancel)
.setNeutralButton(R.string.settings) { _, _ ->
host.context.startActivity(SettingsActivity.newDownloadsSettingsIntent(host.context))
}
.setItems(options) .setItems(options)
.create() .create()
.also { it.show() } .also { it.show() }

View File

@@ -0,0 +1,52 @@
package org.koitharu.kotatsu.details.ui.adapter
import android.graphics.Typeface
import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.model.formatNumber
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
import org.koitharu.kotatsu.databinding.ItemChapterGridBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.list.ui.model.ListModel
fun chapterGridItemAD(
clickListener: OnListItemClickListener<ChapterListItem>,
) = adapterDelegateViewBinding<ChapterListItem, ListModel, ItemChapterGridBinding>(
viewBinding = { inflater, parent -> ItemChapterGridBinding.inflate(inflater, parent, false) },
on = { item, _, _ -> item is ChapterListItem && item.isGrid },
) {
val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener)
itemView.setOnClickListener(eventListener)
itemView.setOnLongClickListener(eventListener)
bind { payloads ->
if (payloads.isEmpty()) {
binding.textViewTitle.text = item.chapter.formatNumber() ?: "?"
}
binding.imageViewNew.isVisible = item.isNew
binding.imageViewCurrent.isVisible = item.isCurrent
binding.imageViewBookmarked.isVisible = item.isBookmarked
binding.imageViewDownloaded.isVisible = item.isDownloaded
when {
item.isCurrent -> {
binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary))
binding.textViewTitle.typeface = Typeface.DEFAULT_BOLD
}
item.isUnread -> {
binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary))
binding.textViewTitle.typeface = Typeface.DEFAULT
}
else -> {
binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorHint))
binding.textViewTitle.typeface = Typeface.DEFAULT
}
}
}
}

View File

@@ -1,23 +1,25 @@
package org.koitharu.kotatsu.details.ui.adapter package org.koitharu.kotatsu.details.ui.adapter
import android.graphics.Typeface
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
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.formatNumber
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.drawableStart import org.koitharu.kotatsu.core.util.ext.drawableStart
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemChapterBinding import org.koitharu.kotatsu.databinding.ItemChapterBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import com.google.android.material.R as materialR import org.koitharu.kotatsu.list.ui.model.ListModel
import com.google.android.material.R as MR
fun chapterListItemAD( fun chapterListItemAD(
clickListener: OnListItemClickListener<ChapterListItem>, clickListener: OnListItemClickListener<ChapterListItem>,
) = adapterDelegateViewBinding<ChapterListItem, ChapterListItem, ItemChapterBinding>( ) = adapterDelegateViewBinding<ChapterListItem, ListModel, ItemChapterBinding>(
{ inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) }, viewBinding = { inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) },
on = { item, _, _ -> item is ChapterListItem && !item.isGrid }
) { ) {
val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener) val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener)
@@ -27,31 +29,38 @@ fun chapterListItemAD(
bind { payloads -> bind { payloads ->
if (payloads.isEmpty()) { if (payloads.isEmpty()) {
binding.textViewTitle.text = item.chapter.name binding.textViewTitle.text = item.chapter.name
binding.textViewNumber.text = item.chapter.formatNumber() binding.textViewDescription.textAndVisible = item.description
binding.textViewDescription.textAndVisible = item.description()
} }
when { when {
item.isCurrent -> { item.isCurrent -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_primary) binding.textViewTitle.drawableStart = ContextCompat.getDrawable(context, R.drawable.ic_current_chapter)
binding.textViewNumber.setTextColor(context.getThemeColor(materialR.attr.colorOnPrimary)) binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary))
binding.textViewDescription.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary))
binding.textViewTitle.typeface = Typeface.DEFAULT_BOLD
binding.textViewDescription.typeface = Typeface.DEFAULT_BOLD
} }
item.isUnread -> { item.isUnread -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_default) binding.textViewTitle.drawableStart = if (item.isNew) {
binding.textViewNumber.setTextColor(context.getThemeColor(materialR.attr.colorOnTertiary)) ContextCompat.getDrawable(context, R.drawable.ic_new)
} else {
null
}
binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary))
binding.textViewDescription.setTextColor(context.getThemeColorStateList(MR.attr.colorOutline))
binding.textViewTitle.typeface = Typeface.DEFAULT
binding.textViewDescription.typeface = Typeface.DEFAULT
} }
else -> { else -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_outline) binding.textViewTitle.drawableStart = null
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorTertiary)) binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorHint))
binding.textViewDescription.setTextColor(context.getThemeColorStateList(android.R.attr.textColorHint))
binding.textViewTitle.typeface = Typeface.DEFAULT
binding.textViewDescription.typeface = Typeface.DEFAULT
} }
} }
binding.imageViewBookmarked.isVisible = item.isBookmarked binding.imageViewBookmarked.isVisible = item.isBookmarked
binding.imageViewDownloaded.isVisible = item.isDownloaded binding.imageViewDownloaded.isVisible = item.isDownloaded
binding.textViewTitle.drawableStart = if (item.isNew) {
ContextCompat.getDrawable(context, R.drawable.ic_new)
} else {
null
}
} }
} }

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