Compare commits

...

116 Commits

Author SHA1 Message Date
c81e8749b6 Update parsers and headers processing 2023-06-28 13:27:26 +03:00
5fa260a0c7 Update issue template 2023-06-28 03:14:30 +03:00
e0ba4e2686 Remove unused code 2023-06-27 12:52:41 +03:00
f188d1c0f3 Remove ongoing flag from background work notifications 2023-06-27 12:34:12 +03:00
CakesTwix
6de55afa27 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: CakesTwix <cakestwix1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-06-27 12:30:56 +03:00
J. Lavoie
21dcb5b754 Translated using Weblate (French)
Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-06-27 12:30:56 +03:00
gallegonovato
9b3ea57db1 Translated using Weblate (Spanish)
Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-06-27 12:30:56 +03:00
kuragehime
032a8607ba Translated using Weblate (Japanese)
Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-06-27 12:30:56 +03:00
Макар Разин
f7303c5957 Translated using Weblate (Serbian)
Currently translated at 29.3% (131 of 447 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.5% (445 of 447 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (447 of 447 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (447 of 447 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/sr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-06-27 12:30:56 +03:00
d696606ef9 Misc fixes 2023-06-27 10:28:47 +03:00
0a6e106a1d Filter local manga files 2023-06-24 13:18:09 +03:00
de1a7f0ca8 Fix IndexOutOfBoundsException in RemoteViewsFactory 2023-06-24 09:38:13 +03:00
9d31e76cc7 Translated using Weblate (Russian)
Currently translated at 100.0% (443 of 443 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
Cookies
20910ffb5d Translated using Weblate (Vietnamese)
Currently translated at 81.2% (360 of 443 strings)

Co-authored-by: Cookies <Nekop1845@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
Clxff H3r4ld0
7497ee6364 Translated using Weblate (Indonesian)
Currently translated at 100.0% (443 of 443 strings)

Co-authored-by: Clxff H3r4ld0 <123844876+clxf12@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
Abay Emes
0f2ed50e18 Translated using Weblate (Kazakh)
Currently translated at 48.8% (213 of 436 strings)

Co-authored-by: Abay Emes <abayemes@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/kk/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
BlackSpectrum
ba066b577b Translated using Weblate (Hindi)
Currently translated at 15.5% (68 of 436 strings)

Co-authored-by: BlackSpectrum <tittan5000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
CakesTwix
4496fe876f Translated using Weblate (Ukrainian)
Currently translated at 100.0% (436 of 436 strings)

Co-authored-by: CakesTwix <cakestwix1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
gallegonovato
a9f5abebf0 Translated using Weblate (Spanish)
Currently translated at 100.0% (443 of 443 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (436 of 436 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
qrynill
bebee2ef27 Translated using Weblate (Norwegian Nynorsk)
Currently translated at 91.7% (399 of 435 strings)

Co-authored-by: qrynill <tryvseu@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nn/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
Макар Разин
4ec2b0c8fe Translated using Weblate (Vietnamese)
Currently translated at 79.3% (345 of 435 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (Korean)

Currently translated at 75.1% (327 of 435 strings)

Translated using Weblate (Greek)

Currently translated at 19.3% (84 of 435 strings)

Translated using Weblate (Serbian)

Currently translated at 28.2% (123 of 435 strings)

Translated using Weblate (Arabic)

Currently translated at 18.1% (79 of 435 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (Italian)

Currently translated at 85.2% (371 of 435 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (435 of 435 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/el/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ko/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
4a7be70898 Update queries in manifest 2023-06-23 16:14:41 +03:00
2bcba1eb21 Configure manga directories 2023-06-22 13:45:29 +03:00
feca7ba3fc Support for custom directories for manga 2023-06-22 10:11:11 +03:00
745b349e5e Ability to remove item from updates 2023-06-21 15:27:20 +03:00
13946783a5 Fix crashes 2023-06-21 15:06:01 +03:00
84e5400522 Download options dialog 2023-06-21 14:54:11 +03:00
02c9a933d2 Fix offline manga details 2023-06-20 17:06:18 +03:00
92af851d3b Option to clear single source cookies 2023-06-20 13:43:09 +03:00
009eb9fe44 Fix recursive sync 2023-06-17 18:34:08 +03:00
fc8a5ccd9f Fix Continue button in offline mode 2023-06-17 18:20:57 +03:00
91f46de547 Fix crashes 2023-06-17 18:11:09 +03:00
d548993e14 Move syncronization to main process 2023-06-17 17:36:12 +03:00
4f32664b33 Respect system PowerSave mode 2023-06-17 16:12:14 +03:00
71b14a3aa8 Refactor FilterOwner 2023-06-17 16:05:08 +03:00
Isira Seneviratne
183a61272e Use ParcelCompat methods. 2023-06-17 15:50:08 +03:00
f1f208ad15 Merge branch 'master' into devel 2023-06-16 10:45:12 +03:00
c6983d794c Fix tablet portrait layout 2023-06-16 10:42:19 +03:00
8228153c83 Fix tablet portrait layout 2023-06-16 10:41:32 +03:00
844bd13a07 Fix filter lifecycle 2023-06-16 10:23:40 +03:00
60a5620134 Schedule workers only on demand 2023-06-16 09:50:02 +03:00
qrynill
dd09a39077 Translated using Weblate (Norwegian Nynorsk)
Currently translated at 82.0% (357 of 435 strings)

Co-authored-by: qrynill <tryvseu@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nn/
Translation: Kotatsu/Strings
2023-06-15 09:45:35 +03:00
MaSHiNiK
1511bd3279 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (435 of 435 strings)

Co-authored-by: MaSHiNiK <infinitymashinik456@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-06-15 09:45:35 +03:00
Макар Разин
259c335607 Translated using Weblate (Turkish)
Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (French)

Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (Portuguese)

Currently translated at 86.2% (375 of 435 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (435 of 435 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/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-06-15 09:45:07 +03:00
Nick New
86367b6d3b Added translation using Weblate (Thai)
Co-authored-by: Nick New <newblackseries@gmail.com>
2023-06-15 09:45:07 +03:00
19b893738d Update parsers 2023-06-15 09:44:35 +03:00
d817ae0394 Fix Cloudflare bypass 2023-06-15 09:43:14 +03:00
d81c22b586 Fix crashes 2023-06-14 10:49:33 +03:00
cd23b044df Merge branch 'devel' 2023-06-13 19:08:45 +03:00
4922881343 Update parsers 2023-06-13 18:41:32 +03:00
ff0d04bea6 Add current screen info to crash reports 2023-06-13 18:32:11 +03:00
97de629c3b Fix BottomSheet duplication 2023-06-13 10:45:29 +03:00
7b482e5bcf Fix shortcuts icons size 2023-06-12 16:16:33 +03:00
fd575b8131 Merge branch 'devel' 2023-06-12 12:57:29 +03:00
c77e023bef Fix color filter preview 2023-06-12 12:54:24 +03:00
a3cf52859b Reader refactoring 2023-06-12 12:47:05 +03:00
5e55bce529 Use indirect image url in bookmarks 2023-06-12 10:54:12 +03:00
b1ba70bf77 Fix settings issues 2023-06-10 15:48:30 +03:00
b930272221 Fix settings background 2023-06-09 16:34:29 +03:00
75305c0b94 Fix fast scroll issues 2023-06-09 16:18:58 +03:00
24b16e2ce2 Fix debug/release sources packages 2023-06-08 21:15:32 +03:00
qrynill
0ccbba6787 Translated using Weblate (Norwegian Nynorsk)
Currently translated at 81.8% (356 of 435 strings)

Co-authored-by: qrynill <tryvseu@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nn/
Translation: Kotatsu/Strings
2023-06-08 18:53:35 +03:00
Reza Almanda
ca314867f2 Translated using Weblate (Indonesian)
Currently translated at 100.0% (435 of 435 strings)

Co-authored-by: Reza Almanda <rezaalmanda27@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-06-08 18:53:35 +03:00
gallegonovato
236e284360 Translated using Weblate (Spanish)
Currently translated at 100.0% (435 of 435 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-06-08 18:53:35 +03:00
Макар Разин
e9a09b6be4 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (435 of 435 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-06-08 18:53:35 +03:00
ViAnh
9e1be337ed Translated using Weblate (Vietnamese)
Currently translated at 78.3% (341 of 435 strings)

Added translation using Weblate (Vietnamese)

Co-authored-by: ViAnh <polaris140698@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-06-08 18:53:35 +03:00
kuragehime
104f2ebfae Translated using Weblate (Japanese)
Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (434 of 434 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-06-08 18:53:35 +03:00
6a2e12dc29 Read button tip in DetailsActivity 2023-06-06 12:35:36 +03:00
9587cb439c Android 5 fixes 2023-06-06 10:10:24 +03:00
c42d0824b0 Add summaries to settings root 2023-06-05 13:54:49 +03:00
09f6dd9b4e Remove unused resources 2023-06-05 13:53:52 +03:00
b494c96e31 Translated using Weblate (Russian)
Currently translated at 99.7% (433 of 434 strings)

Co-authored-by: Zakhar Timoshenko <vp1984tanki@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-06-05 13:46:13 +03:00
0f6d56ee2d Merge remote-tracking branch 'weblate/devel' into devel 2023-06-05 13:23:14 +03:00
8d15691e17 Remove unused resources 2023-06-05 13:04:26 +03:00
GpixeL
bd8b251934 Translated using Weblate (Indonesian)
Currently translated at 100.0% (430 of 430 strings)

Co-authored-by: GpixeL <gamesfire313@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
frablock
2f1b74e45a Translated using Weblate (French)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (French)

Currently translated at 100.0% (430 of 430 strings)

Co-authored-by: frablock <franon1.frablock29@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
2023-06-05 12:56:26 +03:00
FateXBlood
73217b8e11 Translated using Weblate (Nepali)
Currently translated at 34.1% (144 of 422 strings)

Co-authored-by: FateXBlood <zecrofelix@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ne/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
wr131
759df969c9 Translated using Weblate (Chinese (Traditional))
Currently translated at 23.4% (99 of 422 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.2% (419 of 422 strings)

Co-authored-by: wr131 <wr13173893699@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
Clxff H3r4ld0
466e35fffa Translated using Weblate (Indonesian)
Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (422 of 422 strings)

Co-authored-by: Clxff H3r4ld0 <123844876+clxf12@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
Reza Almanda
f44db3dbff Translated using Weblate (Indonesian)
Currently translated at 100.0% (421 of 421 strings)

Co-authored-by: Reza Almanda <rezaalmanda27@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
Макар Разин
315870abcb Translated using Weblate (Ukrainian)
Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (French)

Currently translated at 100.0% (421 of 421 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/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
Bai
3e46b3957c Translated using Weblate (Turkish)
Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Turkish)

Currently translated at 99.7% (416 of 417 strings)

Co-authored-by: Bai <batuhanakkurt000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
gallegonovato
6dc81468d2 Translated using Weblate (Spanish)
Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (422 of 422 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (417 of 417 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
2023-06-05 12:56:26 +03:00
Subham Jena
56bc0dbf07 Translated using Weblate (Odia)
Currently translated at 7.3% (31 of 421 strings)

Translated using Weblate (Odia)

Currently translated at 5.5% (23 of 416 strings)

Co-authored-by: Subham Jena <subhamjena8465@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/or/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
tryvseu
7bc33adca8 Translated using Weblate (Norwegian Nynorsk)
Currently translated at 85.5% (356 of 416 strings)

Co-authored-by: tryvseu <tryvseu@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nn/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
InfinityDouki56
c8794d59f7 Translated using Weblate (Filipino)
Currently translated at 93.0% (387 of 416 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
Insopitus
9c2a57812e Translated using Weblate (Chinese (Simplified))
Currently translated at 99.2% (413 of 416 strings)

Co-authored-by: Insopitus <jessefor@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
kuragehime
6bd5033858 Translated using Weblate (Japanese)
Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (427 of 427 strings)

Translated using Weblate (Japanese)

Currently translated at 91.8% (382 of 416 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
J. Lavoie
e7c2a76219 Translated using Weblate (French)
Currently translated at 100.0% (416 of 416 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
Макар Разин
0934363298 Translated using Weblate (Belarusian)
Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (416 of 416 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
GpixeL
de29527805 Translated using Weblate (Indonesian)
Currently translated at 100.0% (430 of 430 strings)

Co-authored-by: GpixeL <gamesfire313@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-06-05 11:53:43 +02:00
frablock
f11e964f0b Translated using Weblate (French)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (French)

Currently translated at 100.0% (430 of 430 strings)

Co-authored-by: frablock <franon1.frablock29@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
2023-06-05 11:53:42 +02:00
FateXBlood
61a98f54b9 Translated using Weblate (Nepali)
Currently translated at 34.1% (144 of 422 strings)

Co-authored-by: FateXBlood <zecrofelix@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ne/
Translation: Kotatsu/Strings
2023-06-05 11:53:42 +02:00
wr131
50e67daea4 Translated using Weblate (Chinese (Traditional))
Currently translated at 23.4% (99 of 422 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.2% (419 of 422 strings)

Co-authored-by: wr131 <wr13173893699@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2023-06-05 11:53:41 +02:00
Clxff H3r4ld0
0030706226 Translated using Weblate (Indonesian)
Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (422 of 422 strings)

Co-authored-by: Clxff H3r4ld0 <123844876+clxf12@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-06-05 11:53:41 +02:00
Reza Almanda
056ef5433d Translated using Weblate (Indonesian)
Currently translated at 100.0% (421 of 421 strings)

Co-authored-by: Reza Almanda <rezaalmanda27@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-06-05 11:53:40 +02:00
Макар Разин
c14b2ceeff Translated using Weblate (Ukrainian)
Currently translated at 100.0% (431 of 431 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (431 of 431 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (431 of 431 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (French)

Currently translated at 100.0% (421 of 421 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/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-06-05 11:53:40 +02:00
Bai
ff2cf9d18a Translated using Weblate (Turkish)
Currently translated at 100.0% (431 of 431 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Turkish)

Currently translated at 99.7% (416 of 417 strings)

Co-authored-by: Bai <batuhanakkurt000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-06-05 11:53:39 +02:00
gallegonovato
96b6900c70 Translated using Weblate (Spanish)
Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (422 of 422 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (417 of 417 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
2023-06-05 11:53:39 +02:00
Subham Jena
c6228d3fe1 Translated using Weblate (Odia)
Currently translated at 7.3% (31 of 421 strings)

Translated using Weblate (Odia)

Currently translated at 5.5% (23 of 416 strings)

Co-authored-by: Subham Jena <subhamjena8465@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/or/
Translation: Kotatsu/Strings
2023-06-05 11:53:38 +02:00
tryvseu
8ac95e1608 Translated using Weblate (Norwegian Nynorsk)
Currently translated at 85.5% (356 of 416 strings)

Co-authored-by: tryvseu <tryvseu@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nn/
Translation: Kotatsu/Strings
2023-06-05 11:53:38 +02:00
InfinityDouki56
69a9ec354b Translated using Weblate (Filipino)
Currently translated at 93.0% (387 of 416 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-06-05 11:53:37 +02:00
Insopitus
0639d3e6c1 Translated using Weblate (Chinese (Simplified))
Currently translated at 99.2% (413 of 416 strings)

Co-authored-by: Insopitus <jessefor@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-06-05 11:53:37 +02:00
kuragehime
ae5cebd42d Translated using Weblate (Japanese)
Currently translated at 100.0% (431 of 431 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (427 of 427 strings)

Translated using Weblate (Japanese)

Currently translated at 91.8% (382 of 416 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-06-05 11:53:36 +02:00
J. Lavoie
cd8381cbfb Translated using Weblate (French)
Currently translated at 100.0% (416 of 416 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-06-05 11:53:36 +02:00
Макар Разин
3132049a63 Translated using Weblate (Belarusian)
Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (416 of 416 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
2023-06-05 11:53:35 +02:00
bc3a7fc211 Reorganize settings 2023-06-05 12:53:12 +03:00
e794f84c6f Get rid of SlidingPaneLayout in settings 2023-06-05 11:52:56 +03:00
76709dda21 Remove replacement characters from manga description 2023-06-05 10:57:35 +03:00
6dc460bc20 Replace CloudFlareDialog with activity 2023-06-05 10:45:23 +03:00
c2ee548f0a Show errors in global search results 2023-06-05 09:34:56 +03:00
1847759ec3 Refactor list extra provider 2023-06-04 17:37:54 +03:00
02d5dfb375 Check if page file valid before page display 2023-06-04 16:55:51 +03:00
ef9b16da0b Fix favourite categories covers loading 2023-06-02 17:07:26 +03:00
5d1ef983e9 Check available ram before pages prefetch 2023-06-02 17:06:37 +03:00
eb78a776cf Update parsers 2023-06-02 17:04:46 +03:00
316 changed files with 8386 additions and 5422 deletions

View File

@@ -61,4 +61,6 @@ body:
label: Acknowledgements label: Acknowledgements
options: options:
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue. - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true required: true
- label: If this is an issue with a parser, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
required: true

1
.idea/.gitignore generated vendored
View File

@@ -1,3 +1,4 @@
# Default ignored files # Default ignored files
/shelf/ /shelf/
/workspace.xml /workspace.xml
/migrations.xml

3
.weblate Normal file
View File

@@ -0,0 +1,3 @@
[weblate]
url = https://hosted.weblate.org/api/
translation = kotatsu/strings

View File

@@ -15,8 +15,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 33 targetSdkVersion 33
versionCode 547 versionCode 559
versionName '5.1.3' versionName '5.3.2'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -79,17 +79,17 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:44e28b40d3') { implementation('com.github.KotatsuApp:kotatsu-parsers:92bfc7e9fa') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.21' implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1' implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.activity:activity-ktx:1.7.2' implementation 'androidx.activity:activity-ktx:1.7.2'
implementation 'androidx.fragment:fragment-ktx:1.5.7' implementation 'androidx.fragment:fragment-ktx:1.6.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-service:2.6.1' implementation 'androidx.lifecycle:lifecycle-service:2.6.1'
@@ -104,6 +104,7 @@ dependencies {
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1' kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
// TODO https://issuetracker.google.com/issues/254846063
implementation 'androidx.work:work-runtime-ktx:2.8.1' implementation 'androidx.work:work-runtime-ktx:2.8.1'
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.google.guava:guava:32.0.0-android') { implementation('com.google.guava:guava:32.0.0-android') {
@@ -112,9 +113,10 @@ dependencies {
exclude group: 'com.google.j2objc', module: 'j2objc-annotations' exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
} }
implementation 'androidx.room:room-runtime:2.5.1' implementation 'androidx.room:room-runtime:2.5.2'
implementation 'androidx.room:room-ktx:2.5.1' implementation 'androidx.room:room-ktx:2.5.2'
kapt 'androidx.room:room-compiler:2.5.1' //noinspection KaptUsageInsteadOfKsp
kapt 'androidx.room:room-compiler:2.5.2'
implementation 'com.squareup.okhttp3:okhttp:4.11.0' implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
@@ -134,13 +136,13 @@ dependencies {
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2' implementation 'io.noties.markwon:core:4.6.2'
implementation 'ch.acra:acra-http:5.9.7' implementation 'ch.acra:acra-http:5.10.1'
implementation 'ch.acra:acra-dialog:5.9.7' implementation 'ch.acra:acra-dialog:5.10.1'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20230227' testImplementation 'org.json:json:20230618'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:runner:1.5.2'
@@ -150,7 +152,7 @@ dependencies {
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1' androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
androidTestImplementation 'androidx.room:room-testing:2.5.1' androidTestImplementation 'androidx.room:room-testing:2.5.2'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0' androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.46.1' androidTestImplementation 'com.google.dagger:hilt-android-testing:2.46.1'

View File

@@ -23,7 +23,7 @@ import javax.inject.Inject
@HiltAndroidTest @HiltAndroidTest
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ShortcutsUpdaterTest { class AppShortcutManagerTest {
@get:Rule @get:Rule
var hiltRule = HiltAndroidRule(this) var hiltRule = HiltAndroidRule(this)
@@ -32,7 +32,7 @@ class ShortcutsUpdaterTest {
lateinit var historyRepository: HistoryRepository lateinit var historyRepository: HistoryRepository
@Inject @Inject
lateinit var shortcutsUpdater: ShortcutsUpdater lateinit var appShortcutManager: AppShortcutManager
@Inject @Inject
lateinit var database: MangaDatabase lateinit var database: MangaDatabase
@@ -72,6 +72,6 @@ class ShortcutsUpdaterTest {
private suspend fun awaitUpdate() { private suspend fun awaitUpdate() {
val instrumentation = InstrumentationRegistry.getInstrumentation() val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.awaitForIdle() instrumentation.awaitForIdle()
shortcutsUpdater.await() appShortcutManager.await()
} }
} }

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.util package org.koitharu.kotatsu.core.util
import android.util.Log import android.util.Log
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver

View File

@@ -1,3 +1,3 @@
package org.koitharu.kotatsu.util.ext package org.koitharu.kotatsu.core.util.ext
fun Throwable.printStackTraceDebug() = printStackTrace() fun Throwable.printStackTraceDebug() = printStackTrace()

View File

@@ -18,6 +18,22 @@
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" /> <uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" /> <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
</intent>
</queries>
<application <application
android:name="org.koitharu.kotatsu.KotatsuApp" android:name="org.koitharu.kotatsu.KotatsuApp"
@@ -32,6 +48,7 @@
android:largeHeap="true" android:largeHeap="true"
android:localeConfig="@xml/locales" android:localeConfig="@xml/locales"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Kotatsu" android:theme="@style/Theme.Kotatsu"
@@ -98,10 +115,17 @@
<data android:host="sync-settings" /> <data android:host="sync-settings" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name="org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity"
android:label="@string/local_manga_directories" />
<activity <activity
android:name="org.koitharu.kotatsu.browser.BrowserActivity" android:name="org.koitharu.kotatsu.browser.BrowserActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name="org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity" android:name="org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
@@ -184,8 +208,7 @@
<service <service
android:name="org.koitharu.kotatsu.sync.ui.favourites.FavouritesSyncService" android:name="org.koitharu.kotatsu.sync.ui.favourites.FavouritesSyncService"
android:exported="false" android:exported="false"
android:label="@string/favourites" android:label="@string/favourites">
android:process=":sync">
<intent-filter> <intent-filter>
<action android:name="android.content.SyncAdapter" /> <action android:name="android.content.SyncAdapter" />
</intent-filter> </intent-filter>
@@ -196,8 +219,7 @@
<service <service
android:name="org.koitharu.kotatsu.sync.ui.history.HistorySyncService" android:name="org.koitharu.kotatsu.sync.ui.history.HistorySyncService"
android:exported="false" android:exported="false"
android:label="@string/history" android:label="@string/history">
android:process=":sync">
<intent-filter> <intent-filter>
<action android:name="android.content.SyncAdapter" /> <action android:name="android.content.SyncAdapter" />
</intent-filter> </intent-filter>

View File

@@ -12,6 +12,7 @@ import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.acra.ACRA
import org.acra.ReportField import org.acra.ReportField
import org.acra.config.dialog import org.acra.config.dialog
import org.acra.config.httpSender import org.acra.config.httpSender
@@ -19,6 +20,7 @@ import org.acra.data.StringFormat
import org.acra.ktx.initAcra import org.acra.ktx.initAcra
import org.acra.sender.HttpSender import org.acra.sender.HttpSender
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.os.AppValidator
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
@@ -26,6 +28,7 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
import javax.inject.Inject import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
@@ -46,8 +49,15 @@ class KotatsuApp : Application(), Configuration.Provider {
@Inject @Inject
lateinit var workerFactory: HiltWorkerFactory lateinit var workerFactory: HiltWorkerFactory
@Inject
lateinit var appValidator: AppValidator
@Inject
lateinit var workScheduleManager: WorkScheduleManager
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.toString())
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
enableStrictMode() enableStrictMode()
} }
@@ -57,6 +67,7 @@ class KotatsuApp : Application(), Configuration.Provider {
processLifecycleScope.launch(Dispatchers.Default) { processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers() setupDatabaseObservers()
} }
workScheduleManager.init()
WorkServiceStopHelper(applicationContext).setup() WorkServiceStopHelper(applicationContext).setup()
} }
@@ -67,6 +78,10 @@ class KotatsuApp : Application(), Configuration.Provider {
reportFormat = StringFormat.JSON reportFormat = StringFormat.JSON
excludeMatchingSharedPreferencesKeys = listOf( excludeMatchingSharedPreferencesKeys = listOf(
"sources_\\w+", "sources_\\w+",
AppSettings.KEY_APP_PASSWORD,
AppSettings.KEY_PROXY_LOGIN,
AppSettings.KEY_PROXY_ADDRESS,
AppSettings.KEY_PROXY_PASSWORD,
) )
httpSender { httpSender {
uri = getString(R.string.url_error_report) uri = getString(R.string.url_error_report)
@@ -83,8 +98,10 @@ class KotatsuApp : Application(), Configuration.Provider {
ReportField.PHONE_MODEL, ReportField.PHONE_MODEL,
ReportField.STACK_TRACE, ReportField.STACK_TRACE,
ReportField.CRASH_CONFIGURATION, ReportField.CRASH_CONFIGURATION,
ReportField.CUSTOM_DATA,
ReportField.SHARED_PREFERENCES, ReportField.SHARED_PREFERENCES,
) )
dialog { dialog {
text = getString(R.string.crash_text) text = getString(R.string.crash_text)
title = getString(R.string.error_occurred) title = getString(R.string.error_occurred)

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.bookmarks.domain package org.koitharu.kotatsu.bookmarks.domain
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import java.util.Date import java.util.Date
class Bookmark( class Bookmark(
@@ -14,6 +15,20 @@ class Bookmark(
val percent: Float, val percent: Float,
) { ) {
val directImageUrl: String?
get() = if (isImageUrlDirect()) imageUrl else null
fun toMangaPage() = MangaPage(
id = pageId,
url = imageUrl,
preview = null,
source = manga.source,
)
private fun isImageUrlDirect(): Boolean {
return imageUrl.substringAfterLast('.').length in 2..4
}
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false

View File

@@ -15,8 +15,8 @@ import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.core.util.ext.mapItems
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import javax.inject.Inject import javax.inject.Inject
@Reusable @Reusable

View File

@@ -95,8 +95,11 @@ class BookmarksFragment :
override fun onItemClick(item: Bookmark, view: View) { override fun onItemClick(item: Bookmark, view: View) {
if (selectionController?.onItemClick(item.manga, item.pageId) != true) { if (selectionController?.onItemClick(item.manga, item.pageId) != true) {
val intent = ReaderActivity.newIntent(view.context, item) val intent = ReaderActivity.IntentBuilder(view.context)
startActivity(intent, scaleUpActivityOptionsOf(view).toBundle()) .bookmark(item)
.incognito(true)
.build()
startActivity(intent, scaleUpActivityOptionsOf(view))
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show() Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
} }
} }

View File

@@ -28,7 +28,8 @@ fun bookmarkListAD(
binding.root.setOnLongClickListener(listener) binding.root.setOnLongClickListener(listener)
bind { bind {
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageUrl)?.run { val data: Any = item.directImageUrl ?: item.toMangaPage()
binding.imageViewThumb.newImageRequest(lifecycleOwner, data)?.run {
size(CoverSizeResolver(binding.imageViewThumb)) size(CoverSizeResolver(binding.imageViewThumb))
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)

View File

@@ -8,6 +8,7 @@ import android.net.Uri
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 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
@@ -16,6 +17,7 @@ import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
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.catchingWebViewUnavailability
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.network.UserAgents
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
@@ -34,8 +36,9 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
} }
with(viewBinding.webView.settings) { with(viewBinding.webView.settings) {
javaScriptEnabled = true javaScriptEnabled = true
userAgentString = CommonHeadersInterceptor.userAgentChrome userAgentString = UserAgents.CHROME_MOBILE
} }
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)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView) onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
@@ -103,6 +106,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
viewBinding.webView.stopLoading()
viewBinding.webView.destroy() viewBinding.webView.destroy()
} }

View File

@@ -0,0 +1,175 @@
package org.koitharu.kotatsu.browser.cloudflare
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import android.webkit.CookieManager
import android.webkit.WebSettings
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.graphics.Insets
import androidx.core.net.toUri
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.network.UserAgents
import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint
class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCallback {
private var pendingResult = RESULT_CANCELED
@Inject
lateinit var cookieJar: MutableCookieJar
private var onBackPressedCallback: WebViewBackPressedCallback? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
return
}
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
val url = intent?.dataString.orEmpty()
with(viewBinding.webView.settings) {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
userAgentString = intent?.getStringExtra(ARG_UA) ?: UserAgents.CHROME_MOBILE
}
viewBinding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
onBackPressedDispatcher.addCallback(it)
}
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
if (savedInstanceState != null) {
return
}
if (url.isEmpty()) {
finishAfterTransition()
} else {
onTitleChanged(getString(R.string.loading_), url)
viewBinding.webView.loadUrl(url)
}
}
override fun onDestroy() {
viewBinding.webView.run {
stopLoading()
destroy()
}
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 onWindowInsetsChanged(insets: Insets) {
viewBinding.appbar.updatePadding(
top = insets.top,
)
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom,
)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> {
viewBinding.webView.stopLoading()
finishAfterTransition()
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onResume() {
super.onResume()
viewBinding.webView.onResume()
}
override fun onPause() {
viewBinding.webView.onPause()
super.onPause()
}
override fun finish() {
setResult(pendingResult)
super.finish()
}
override fun onPageLoaded() {
viewBinding.progressBar.isInvisible = true
}
override fun onCheckPassed() {
pendingResult = RESULT_OK
finishAfterTransition()
}
override fun onLoadingStateChanged(isLoading: Boolean) {
viewBinding.progressBar.isVisible = isLoading
}
override fun onHistoryChanged() {
onBackPressedCallback?.onHistoryChanged()
}
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
setTitle(title)
supportActionBar?.subtitle = subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
}
class Contract : ActivityResultContract<Pair<String, Headers?>, TaggedActivityResult>() {
override fun createIntent(context: Context, input: Pair<String, Headers?>): Intent {
return newIntent(context, input.first, input.second)
}
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
return TaggedActivityResult(TAG, resultCode)
}
}
companion object {
const val TAG = "CloudFlareActivity"
private const val ARG_UA = "ua"
fun newIntent(
context: Context,
url: String,
headers: Headers?,
) = Intent(context, CloudFlareActivity::class.java).apply {
data = url.toUri()
headers?.get(CommonHeaders.USER_AGENT)?.let {
putExtra(ARG_UA, it)
}
}
}
}

View File

@@ -1,123 +0,0 @@
package org.koitharu.kotatsu.browser.cloudflare
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebSettings
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isInvisible
import androidx.fragment.app.setFragmentResult
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import okhttp3.Headers
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding
import javax.inject.Inject
@AndroidEntryPoint
class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), CloudFlareCallback {
private lateinit var url: String
private val pendingResult = Bundle(1)
@Inject
lateinit var cookieJar: MutableCookieJar
private var onBackPressedCallback: WebViewBackPressedCallback? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
url = requireArguments().getString(ARG_URL).orEmpty()
}
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
) = FragmentCloudflareBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: FragmentCloudflareBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
with(binding.webView.settings) {
javaScriptEnabled = true
cacheMode = WebSettings.LOAD_DEFAULT
domStorageEnabled = true
databaseEnabled = true
userAgentString = arguments?.getString(ARG_UA) ?: CommonHeadersInterceptor.userAgentChrome
}
binding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
CookieManager.getInstance().setAcceptThirdPartyCookies(binding.webView, true)
if (url.isEmpty()) {
dismissAllowingStateLoss()
} else {
binding.webView.loadUrl(url)
}
}
override fun onDestroyView() {
requireViewBinding().webView.stopLoading()
requireViewBinding().webView.destroy()
onBackPressedCallback = null
super.onDestroyView()
}
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder).setNegativeButton(android.R.string.cancel, null)
}
override fun onDialogCreated(dialog: AlertDialog) {
super.onDialogCreated(dialog)
onBackPressedCallback = WebViewBackPressedCallback(requireViewBinding().webView).also {
dialog.onBackPressedDispatcher.addCallback(it)
}
}
override fun onResume() {
super.onResume()
requireViewBinding().webView.onResume()
}
override fun onPause() {
requireViewBinding().webView.onPause()
super.onPause()
}
override fun onDismiss(dialog: DialogInterface) {
setFragmentResult(TAG, pendingResult)
super.onDismiss(dialog)
}
override fun onPageLoaded() {
viewBinding?.progressBar?.isInvisible = true
}
override fun onCheckPassed() {
pendingResult.putBoolean(EXTRA_RESULT, true)
dismissAllowingStateLoss()
}
override fun onHistoryChanged() {
onBackPressedCallback?.onHistoryChanged()
}
companion object {
const val TAG = "CloudFlareDialog"
const val EXTRA_RESULT = "result"
private const val ARG_URL = "url"
private const val ARG_UA = "ua"
fun newInstance(url: String, headers: Headers?) = CloudFlareDialog().withArgs(2) {
putString(ARG_URL, url)
headers?.get(CommonHeaders.USER_AGENT)?.let {
putString(ARG_UA, it)
}
}
}
}

View File

@@ -29,19 +29,18 @@ import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.StubContentCache import org.koitharu.kotatsu.core.cache.StubContentCache
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.network.* import org.koitharu.kotatsu.core.network.*
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.util.AcraScreenLogger
import org.koitharu.kotatsu.core.util.IncognitoModeIndicator import org.koitharu.kotatsu.core.util.IncognitoModeIndicator
import org.koitharu.kotatsu.core.util.ext.activityManager import org.koitharu.kotatsu.core.util.ext.activityManager
import org.koitharu.kotatsu.core.util.ext.connectivityManager import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.domain.ListExtraProviderImpl
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher import org.koitharu.kotatsu.local.data.CbzFetcher
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
@@ -65,9 +64,6 @@ interface AppModule {
@Binds @Binds
fun bindImageGetter(coilImageGetter: CoilImageGetter): Html.ImageGetter fun bindImageGetter(coilImageGetter: CoilImageGetter): Html.ImageGetter
@Binds
fun bindListExtraProvider(impl: ListExtraProviderImpl): ListExtraProvider
companion object { companion object {
@Provides @Provides
@@ -130,12 +126,12 @@ interface AppModule {
@ElementsIntoSet @ElementsIntoSet
fun provideDatabaseObservers( fun provideDatabaseObservers(
widgetUpdater: WidgetUpdater, widgetUpdater: WidgetUpdater,
shortcutsUpdater: ShortcutsUpdater, appShortcutManager: AppShortcutManager,
backupObserver: BackupObserver, backupObserver: BackupObserver,
syncController: SyncController, syncController: SyncController,
): Set<@JvmSuppressWildcards InvalidationTracker.Observer> = arraySetOf( ): Set<@JvmSuppressWildcards InvalidationTracker.Observer> = arraySetOf(
widgetUpdater, widgetUpdater,
shortcutsUpdater, appShortcutManager,
backupObserver, backupObserver,
syncController, syncController,
) )
@@ -146,10 +142,12 @@ interface AppModule {
appProtectHelper: AppProtectHelper, appProtectHelper: AppProtectHelper,
activityRecreationHandle: ActivityRecreationHandle, activityRecreationHandle: ActivityRecreationHandle,
incognitoModeIndicator: IncognitoModeIndicator, incognitoModeIndicator: IncognitoModeIndicator,
acraScreenLogger: AcraScreenLogger,
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf( ): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
appProtectHelper, appProtectHelper,
activityRecreationHandle, activityRecreationHandle,
incognitoModeIndicator, incognitoModeIndicator,
acraScreenLogger,
) )
@Provides @Provides

View File

@@ -33,11 +33,21 @@ abstract class ErrorObserver(
return resolver != null && ExceptionResolver.canResolve(error) return resolver != null && ExceptionResolver.canResolve(error)
} }
private fun isAlive(): Boolean {
return when {
fragment != null -> fragment.view != null
activity != null -> !activity.isDestroyed
else -> true
}
}
protected fun resolve(error: Throwable) { protected fun resolve(error: Throwable) {
lifecycleScope.launch { if (isAlive()) {
val isResolved = resolver?.resolve(error) ?: false lifecycleScope.launch {
if (isActive) { val isResolved = resolver?.resolve(error) ?: false
onResolved?.accept(isResolved) if (isActive) {
onResolved?.accept(isResolved)
}
} }
} }
} }

View File

@@ -6,15 +6,13 @@ import androidx.annotation.StringRes
import androidx.collection.ArrayMap import androidx.collection.ArrayMap
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Headers import okhttp3.Headers
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog 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.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.core.util.isSuccess
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.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -23,20 +21,26 @@ import kotlin.coroutines.Continuation
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
class ExceptionResolver private constructor( class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
private val activity: FragmentActivity?,
private val fragment: Fragment?,
) : ActivityResultCallback<TaggedActivityResult> {
private val continuations = ArrayMap<String, Continuation<Boolean>>(1) private val continuations = ArrayMap<String, Continuation<Boolean>>(1)
private lateinit var sourceAuthContract: ActivityResultLauncher<MangaSource> private val activity: FragmentActivity?
private val fragment: Fragment?
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
private val cloudflareContract: ActivityResultLauncher<Pair<String, Headers?>>
constructor(activity: FragmentActivity) : this(activity = activity, fragment = null) { constructor(activity: FragmentActivity) {
this.activity = activity
fragment = null
sourceAuthContract = activity.registerForActivityResult(SourceAuthActivity.Contract(), this) sourceAuthContract = activity.registerForActivityResult(SourceAuthActivity.Contract(), this)
cloudflareContract = activity.registerForActivityResult(CloudFlareActivity.Contract(), this)
} }
constructor(fragment: Fragment) : this(activity = null, fragment = fragment) { constructor(fragment: Fragment) {
this.fragment = fragment
activity = null
sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this) sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this)
cloudflareContract = fragment.registerForActivityResult(CloudFlareActivity.Contract(), this)
} }
override fun onActivityResult(result: TaggedActivityResult) { override fun onActivityResult(result: TaggedActivityResult) {
@@ -58,22 +62,9 @@ class ExceptionResolver private constructor(
else -> false else -> false
} }
private suspend fun resolveCF(url: String, headers: Headers): Boolean { private suspend fun resolveCF(url: String, headers: Headers): Boolean = suspendCoroutine { cont ->
val dialog = CloudFlareDialog.newInstance(url, headers) continuations[CloudFlareActivity.TAG] = cont
val fm = getFragmentManager() cloudflareContract.launch(url to headers)
return suspendCancellableCoroutine { cont ->
fm.clearFragmentResult(CloudFlareDialog.TAG)
continuations[CloudFlareDialog.TAG] = cont
fm.setFragmentResultListener(CloudFlareDialog.TAG, checkNotNull(fragment ?: activity)) { key, result ->
continuations.remove(key)?.resume(result.getBoolean(CloudFlareDialog.EXTRA_RESULT))
}
dialog.show(fm, CloudFlareDialog.TAG)
cont.invokeOnCancellation {
continuations.remove(CloudFlareDialog.TAG, cont)
fm.clearFragmentResultListener(CloudFlareDialog.TAG)
dialog.dismissAllowingStateLoss()
}
}
} }
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont -> private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->

View File

@@ -0,0 +1,17 @@
package org.koitharu.kotatsu.core.exceptions.resolve
import android.view.View
import android.widget.Toast
import androidx.fragment.app.Fragment
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
class ToastErrorObserver(
host: View,
fragment: Fragment?,
) : ErrorObserver(host, fragment, null, null) {
override suspend fun emit(value: Throwable) {
val toast = Toast.makeText(host.context, value.getDisplayMessage(host.context.resources), Toast.LENGTH_SHORT)
toast.show()
}
}

View File

@@ -1,9 +1,5 @@
package org.koitharu.kotatsu.core.github package org.koitharu.kotatsu.core.github
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -14,28 +10,22 @@ import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.network.BaseHttpClient import org.koitharu.kotatsu.core.network.BaseHttpClient
import org.koitharu.kotatsu.core.os.AppValidator
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.asArrayList import org.koitharu.kotatsu.core.util.ext.asArrayList
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
import org.koitharu.kotatsu.parsers.util.parseJsonArray import org.koitharu.kotatsu.parsers.util.parseJsonArray
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
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
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"
private const val CONTENT_TYPE_APK = "application/vnd.android.package-archive" private const val CONTENT_TYPE_APK = "application/vnd.android.package-archive"
@Singleton @Singleton
class AppUpdateRepository @Inject constructor( class AppUpdateRepository @Inject constructor(
@ApplicationContext private val context: Context, private val appValidator: AppValidator,
private val settings: AppSettings, private val settings: AppSettings,
@BaseHttpClient private val okHttp: OkHttpClient, @BaseHttpClient private val okHttp: OkHttpClient,
) { ) {
@@ -85,7 +75,7 @@ class AppUpdateRepository @Inject constructor(
} }
fun isUpdateSupported(): Boolean { fun isUpdateSupported(): Boolean {
return BuildConfig.DEBUG || getCertificateSHA1Fingerprint() == CERT_SHA1 return BuildConfig.DEBUG || appValidator.isOriginalApp
} }
suspend fun getCurrentVersionChangelog(): String? { suspend fun getCurrentVersionChangelog(): String? {
@@ -94,22 +84,6 @@ class AppUpdateRepository @Inject constructor(
return available.find { x -> x.versionId == currentVersion }?.description return available.find { x -> x.versionId == currentVersion }?.description
} }
@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 inline fun JSONArray.find(predicate: (JSONObject) -> Boolean): JSONObject? { private inline fun JSONArray.find(predicate: (JSONObject) -> Boolean): JSONObject? {
val size = length() val size = length()
for (i in 0 until size) { for (i in 0 until size) {

View File

@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.core.util.ext.subdir import org.koitharu.kotatsu.core.util.ext.subdir
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.text.SimpleDateFormat import java.text.SimpleDateFormat

View File

@@ -8,10 +8,14 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
@JvmName("mangaIds")
fun Collection<Manga>.ids() = mapToSet { it.id } fun Collection<Manga>.ids() = mapToSet { it.id }
fun Collection<Manga>.distinctById() = distinctBy { it.id } fun Collection<Manga>.distinctById() = distinctBy { it.id }
@JvmName("chaptersIds")
fun Collection<MangaChapter>.ids() = mapToSet { it.id }
fun Collection<ChapterListItem>.countChaptersByBranch(): Int { fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) { if (size <= 1) {
return size return size

View File

@@ -41,6 +41,10 @@ class ParcelableManga(
return 0 return 0
} }
override fun toString(): String {
return "ParcelableManga(manga=$manga, withChapters=$withChapters)"
}
companion object CREATOR : Parcelable.Creator<ParcelableManga> { companion object CREATOR : Parcelable.Creator<ParcelableManga> {
override fun createFromParcel(parcel: Parcel): ParcelableManga { override fun createFromParcel(parcel: Parcel): ParcelableManga {
return ParcelableManga(parcel) return ParcelableManga(parcel)

View File

@@ -23,6 +23,10 @@ class ParcelableMangaChapters(
return 0 return 0
} }
override fun toString(): String {
return "ParcelableMangaChapters(chapters=$chapters)"
}
companion object CREATOR : Parcelable.Creator<ParcelableMangaChapters> { companion object CREATOR : Parcelable.Creator<ParcelableMangaChapters> {
override fun createFromParcel(parcel: Parcel): ParcelableMangaChapters { override fun createFromParcel(parcel: Parcel): ParcelableMangaChapters {
return ParcelableMangaChapters(parcel) return ParcelableMangaChapters(parcel)
@@ -32,4 +36,4 @@ class ParcelableMangaChapters(
return arrayOfNulls(size) return arrayOfNulls(size)
} }
} }
} }

View File

@@ -23,6 +23,10 @@ class ParcelableMangaPages(
return 0 return 0
} }
override fun toString(): String {
return "ParcelableMangaPages(pages=$pages)"
}
companion object CREATOR : Parcelable.Creator<ParcelableMangaPages> { companion object CREATOR : Parcelable.Creator<ParcelableMangaPages> {
override fun createFromParcel(parcel: Parcel): ParcelableMangaPages { override fun createFromParcel(parcel: Parcel): ParcelableMangaPages {
return ParcelableMangaPages(parcel) return ParcelableMangaPages(parcel)
@@ -32,4 +36,4 @@ class ParcelableMangaPages(
return arrayOfNulls(size) return arrayOfNulls(size)
} }
} }
} }

View File

@@ -24,6 +24,10 @@ class ParcelableMangaTags(
return 0 return 0
} }
override fun toString(): String {
return "ParcelableMangaTags(tags=$tags)"
}
companion object CREATOR : Parcelable.Creator<ParcelableMangaTags> { companion object CREATOR : Parcelable.Creator<ParcelableMangaTags> {
override fun createFromParcel(parcel: Parcel): ParcelableMangaTags { override fun createFromParcel(parcel: Parcel): ParcelableMangaTags {
return ParcelableMangaTags(parcel) return ParcelableMangaTags(parcel)

View File

@@ -1,7 +1,7 @@
package org.koitharu.kotatsu.core.network package org.koitharu.kotatsu.core.network
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.IOException import java.io.IOException
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Proxy import java.net.Proxy

View File

@@ -13,13 +13,13 @@ private const val SERVER_CLOUDFLARE = "cloudflare"
class CloudFlareInterceptor : Interceptor { class CloudFlareInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request() val response = chain.proceed(chain.request())
val response = chain.proceed(request)
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) { if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
if (response.header(HEADER_SERVER)?.startsWith(SERVER_CLOUDFLARE) == true) { if (response.header(HEADER_SERVER)?.startsWith(SERVER_CLOUDFLARE) == true) {
val request = response.request
response.closeQuietly() response.closeQuietly()
throw CloudFlareProtectedException( throw CloudFlareProtectedException(
url = response.request.url.toString(), url = request.url.toString(),
headers = request.headers, headers = request.headers,
) )
} }

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.network package org.koitharu.kotatsu.core.network
import android.os.Build
import android.util.Log import android.util.Log
import dagger.Lazy import dagger.Lazy
import okhttp3.Headers import okhttp3.Headers
@@ -10,11 +9,11 @@ import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
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.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 org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.net.IDN import java.net.IDN
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -39,7 +38,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] = userAgentFallback headersBuilder[CommonHeaders.USER_AGENT] = UserAgents.CHROME_MOBILE
} }
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)
@@ -62,26 +61,4 @@ class CommonHeadersInterceptor @Inject constructor(
override fun request(): Request = request override fun request(): Request = request
} }
companion object {
val userAgentFallback
get() = "Kotatsu/%s (Android %s; %s; %s %s; %s)".format(
BuildConfig.VERSION_NAME,
Build.VERSION.RELEASE,
Build.MODEL,
Build.BRAND,
Build.DEVICE,
Locale.getDefault().language,
)
val userAgentChrome
get() = (
"Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/100.0.4896.127 Mobile Safari/537.36"
).format(
Build.VERSION.RELEASE,
Build.MODEL,
)
}
} }

View File

@@ -6,7 +6,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.dnsoverhttps.DnsOverHttps import okhttp3.dnsoverhttps.DnsOverHttps
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.net.InetAddress import java.net.InetAddress
import java.net.UnknownHostException import java.net.UnknownHostException

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.network
import android.annotation.SuppressLint import android.annotation.SuppressLint
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.security.SecureRandom import java.security.SecureRandom
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext

View File

@@ -4,6 +4,7 @@ import android.webkit.CookieManager
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import okhttp3.Cookie import okhttp3.Cookie
import okhttp3.HttpUrl import okhttp3.HttpUrl
import org.koitharu.kotatsu.core.util.ext.newBuilder
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
@@ -30,6 +31,21 @@ class AndroidCookieJar : MutableCookieJar {
} }
} }
override fun removeCookies(url: HttpUrl) {
val cookies = loadForRequest(url)
if (cookies.isEmpty()) {
return
}
val urlString = url.toString()
for (c in cookies) {
val nc = c.newBuilder()
.expiresAt(System.currentTimeMillis() - 100000)
.build()
cookieManager.setCookie(urlString, nc.toString())
}
check(loadForRequest(url).isEmpty())
}
override suspend fun clear() = suspendCoroutine<Boolean> { continuation -> override suspend fun clear() = suspendCoroutine<Boolean> { continuation ->
cookieManager.removeAllCookies(continuation::resume) cookieManager.removeAllCookies(continuation::resume)
} }

View File

@@ -13,5 +13,8 @@ interface MutableCookieJar : CookieJar {
@WorkerThread @WorkerThread
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>)
@WorkerThread
fun removeCookies(url: HttpUrl)
suspend fun clear(): Boolean suspend fun clear(): Boolean
} }

View File

@@ -8,7 +8,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.Cookie import okhttp3.Cookie
import okhttp3.HttpUrl import okhttp3.HttpUrl
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
private const val PREFS_NAME = "cookies" private const val PREFS_NAME = "cookies"
@@ -21,6 +21,7 @@ class PreferencesCookieJar(
private var isLoaded = false private var isLoaded = false
@WorkerThread @WorkerThread
@Synchronized
override fun loadForRequest(url: HttpUrl): List<Cookie> { override fun loadForRequest(url: HttpUrl): List<Cookie> {
loadPersistent() loadPersistent()
val expired = HashSet<String>() val expired = HashSet<String>()
@@ -40,6 +41,7 @@ class PreferencesCookieJar(
} }
@WorkerThread @WorkerThread
@Synchronized
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) { override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val wrapped = cookies.map { CookieWrapper(it) } val wrapped = cookies.map { CookieWrapper(it) }
prefs.edit(commit = true) { prefs.edit(commit = true) {
@@ -53,6 +55,22 @@ class PreferencesCookieJar(
} }
} }
@Synchronized
@WorkerThread
override fun removeCookies(url: HttpUrl) {
loadPersistent()
val toRemove = HashSet<String>()
for ((key, cookie) in cache) {
if (cookie.isExpired() || cookie.cookie.matches(url)) {
toRemove += key
}
}
if (toRemove.isNotEmpty()) {
cache.removeAll(toRemove)
removePersistent(toRemove)
}
}
override suspend fun clear(): Boolean { override suspend fun clear(): Boolean {
cache.clear() cache.clear()
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {

View File

@@ -15,7 +15,6 @@ import androidx.core.graphics.drawable.toBitmap
import androidx.room.InvalidationTracker import androidx.room.InvalidationTracker
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Precision
import coil.size.Scale import coil.size.Scale
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -25,18 +24,19 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class ShortcutsUpdater @Inject constructor( class AppShortcutManager @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val coil: ImageLoader, private val coil: ImageLoader,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
@@ -128,8 +128,8 @@ class ShortcutsUpdater @Inject constructor(
.data(manga.coverUrl) .data(manga.coverUrl)
.size(iconSize.width, iconSize.height) .size(iconSize.width, iconSize.height)
.tag(manga.source) .tag(manga.source)
.precision(Precision.EXACT)
.scale(Scale.FILL) .scale(Scale.FILL)
.transformations(ThumbnailTransformation())
.build(), .build(),
).getDrawableOrThrow().toBitmap() ).getDrawableOrThrow().toBitmap()
}.fold( }.fold(
@@ -142,8 +142,9 @@ class ShortcutsUpdater @Inject constructor(
.setLongLabel(manga.title) .setLongLabel(manga.title)
.setIcon(icon) .setIcon(icon)
.setIntent( .setIntent(
ReaderActivity.newIntent(context, manga.id) ReaderActivity.IntentBuilder(context)
.setAction(ReaderActivity.ACTION_MANGA_READ), .mangaId(manga.id)
.build(),
) )
} }

View File

@@ -0,0 +1,46 @@
package org.koitharu.kotatsu.core.os
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
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.Singleton
@Singleton
class AppValidator @Inject constructor(
@ApplicationContext private val context: Context,
) {
val isOriginalApp by lazy {
getCertificateSHA1Fingerprint() == CERT_SHA1
}
@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 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

@@ -9,6 +9,6 @@ fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaPa
return if (source == MangaSource.DUMMY) { return if (source == MangaSource.DUMMY) {
DummyParser(loaderContext) DummyParser(loaderContext)
} else { } else {
source.newParser(loaderContext) loaderContext.newParserInstance(source)
} }
} }

View File

@@ -1,37 +0,0 @@
package org.koitharu.kotatsu.core.parser
import android.content.Context
import androidx.annotation.ColorRes
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.model.MangaTag
import javax.inject.Inject
@Reusable
class MangaTagHighlighter @Inject constructor(
@ApplicationContext context: Context,
) {
private val dict by lazy {
context.resources.openRawResource(R.raw.tags_redlist).use {
val set = HashSet<String>()
it.bufferedReader().forEachLine { x ->
val line = x.trim()
if (line.isNotEmpty()) {
set.add(line)
}
}
set
}
}
@ColorRes
fun getTint(tag: MangaTag): Int {
return if (tag.title.lowercase() in dict) {
R.color.warning
} else {
0
}
}
}

View File

@@ -21,9 +21,11 @@ import org.koitharu.kotatsu.core.util.ext.filterToSet
import org.koitharu.kotatsu.core.util.ext.getEnumValue import org.koitharu.kotatsu.core.util.ext.getEnumValue
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.core.util.ext.toUriOrNull
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
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.shelf.domain.model.ShelfSection import org.koitharu.kotatsu.shelf.domain.model.ShelfSection
import java.io.File import java.io.File
@@ -234,14 +236,28 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
if (key == null) ScreenshotsPolicy.ALLOW else ScreenshotsPolicy.valueOf(key) if (key == null) ScreenshotsPolicy.ALLOW else ScreenshotsPolicy.valueOf(key)
}.getOrDefault(ScreenshotsPolicy.ALLOW) }.getOrDefault(ScreenshotsPolicy.ALLOW)
var userSpecifiedMangaDirectories: Set<File>
get() {
val set = prefs.getStringSet(KEY_LOCAL_MANGA_DIRS, emptySet()).orEmpty()
return set.mapNotNullToSet { File(it).takeIfReadable() }
}
set(value) {
val set = value.mapToSet { it.absolutePath }
prefs.edit { putStringSet(KEY_LOCAL_MANGA_DIRS, set) }
}
var mangaStorageDir: File? var mangaStorageDir: File?
get() = prefs.getString(KEY_LOCAL_STORAGE, null)?.let { get() = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
File(it) File(it)
}?.takeIf { it.exists() } }?.takeIf { it.exists() && it in userSpecifiedMangaDirectories }
set(value) = prefs.edit { set(value) = prefs.edit {
if (value == null) { if (value == null) {
remove(KEY_LOCAL_STORAGE) remove(KEY_LOCAL_STORAGE)
} else { } else {
val userDirs = userSpecifiedMangaDirectories
if (value !in userDirs) {
userSpecifiedMangaDirectories = userDirs + value
}
putString(KEY_LOCAL_STORAGE, value.path) putString(KEY_LOCAL_STORAGE, value.path)
} }
} }
@@ -461,6 +477,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PROXY_LOGIN = "proxy_login" const val KEY_PROXY_LOGIN = "proxy_login"
const val KEY_PROXY_PASSWORD = "proxy_password" const val KEY_PROXY_PASSWORD = "proxy_password"
const val KEY_IMAGES_PROXY = "images_proxy" const val KEY_IMAGES_PROXY = "images_proxy"
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

View File

@@ -27,6 +27,7 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
.setView(binding.root) .setView(binding.root)
.run(::onBuildDialog) .run(::onBuildDialog)
.create() .create()
.also(::onDialogCreated)
} }
final override fun onCreateView( final override fun onCreateView(

View File

@@ -45,7 +45,7 @@ abstract class BaseActivity<B : ViewBinding> :
protected val exceptionResolver = ExceptionResolver(this) protected val exceptionResolver = ExceptionResolver(this)
@JvmField @JvmField
protected val insetsDelegate = WindowInsetsDelegate(this) protected val insetsDelegate = WindowInsetsDelegate()
@JvmField @JvmField
val actionModeDelegate = ActionModeDelegate() val actionModeDelegate = ActionModeDelegate()
@@ -62,6 +62,7 @@ abstract class BaseActivity<B : ViewBinding> :
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
insetsDelegate.handleImeInsets = true insetsDelegate.handleImeInsets = true
insetsDelegate.addInsetsListener(this)
putDataToExtras(intent) putDataToExtras(intent)
} }

View File

@@ -26,7 +26,7 @@ abstract class BaseFragment<B : ViewBinding> :
protected val exceptionResolver = ExceptionResolver(this) protected val exceptionResolver = ExceptionResolver(this)
@JvmField @JvmField
protected val insetsDelegate = WindowInsetsDelegate(this) protected val insetsDelegate = WindowInsetsDelegate()
protected val actionModeDelegate: ActionModeDelegate protected val actionModeDelegate: ActionModeDelegate
get() = (requireActivity() as BaseActivity<*>).actionModeDelegate get() = (requireActivity() as BaseActivity<*>).actionModeDelegate
@@ -44,11 +44,13 @@ abstract class BaseFragment<B : ViewBinding> :
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
insetsDelegate.onViewCreated(view) insetsDelegate.onViewCreated(view)
insetsDelegate.addInsetsListener(this)
onViewBindingCreated(requireViewBinding(), savedInstanceState) onViewBindingCreated(requireViewBinding(), savedInstanceState)
} }
override fun onDestroyView() { override fun onDestroyView() {
viewBinding = null viewBinding = null
insetsDelegate.removeInsetsListener(this)
insetsDelegate.onDestroyView() insetsDelegate.onDestroyView()
super.onDestroyView() super.onDestroyView()
} }

View File

@@ -4,9 +4,11 @@ import android.graphics.Color
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager import android.view.WindowManager
import androidx.core.content.ContextCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.R
abstract class BaseFullscreenActivity<B : ViewBinding> : abstract class BaseFullscreenActivity<B : ViewBinding> :
BaseActivity<B>() { BaseActivity<B>() {
@@ -18,7 +20,11 @@ abstract class BaseFullscreenActivity<B : ViewBinding> :
with(window) { with(window) {
insetsControllerCompat = WindowInsetsControllerCompat(this, decorView) insetsControllerCompat = WindowInsetsControllerCompat(this, decorView)
statusBarColor = Color.TRANSPARENT statusBarColor = Color.TRANSPARENT
navigationBarColor = Color.TRANSPARENT navigationBarColor = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
ContextCompat.getColor(this@BaseFullscreenActivity, R.color.dim)
} else {
Color.TRANSPARENT
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
attributes.layoutInDisplayCutoutMode = attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES

View File

@@ -12,10 +12,10 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.settings.SettingsHeadersFragment import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.settings.SettingsActivity
import javax.inject.Inject import javax.inject.Inject
@Suppress("LeakingThis")
@AndroidEntryPoint @AndroidEntryPoint
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(), PreferenceFragmentCompat(),
@@ -26,27 +26,28 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
lateinit var settings: AppSettings lateinit var settings: AppSettings
@JvmField @JvmField
protected val insetsDelegate = WindowInsetsDelegate(this) protected val insetsDelegate = WindowInsetsDelegate()
override val recyclerView: RecyclerView override val recyclerView: RecyclerView
get() = listView get() = listView
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
view.setBackgroundColor(view.context.getThemeColor(android.R.attr.colorBackground))
listView.clipToPadding = false listView.clipToPadding = false
insetsDelegate.onViewCreated(view) insetsDelegate.onViewCreated(view)
insetsDelegate.addInsetsListener(this)
} }
override fun onDestroyView() { override fun onDestroyView() {
insetsDelegate.removeInsetsListener(this)
insetsDelegate.onDestroyView() insetsDelegate.onDestroyView()
super.onDestroyView() super.onDestroyView()
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (titleId != 0) { setTitle(if (titleId != 0) getString(titleId) else null)
setTitle(getString(titleId))
}
} }
@CallSuper @CallSuper
@@ -56,9 +57,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
) )
} }
@Suppress("UsePropertyAccessSyntax") protected fun setTitle(title: CharSequence?) {
protected fun setTitle(title: CharSequence) { (activity as? SettingsActivity)?.setSectionTitle(title)
(parentFragment as? SettingsHeadersFragment)?.setTitle(title)
?: activity?.setTitle(title)
} }
} }

View File

@@ -17,7 +17,7 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.util.ext.EventFlow import org.koitharu.kotatsu.core.util.ext.EventFlow
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.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext

View File

@@ -9,7 +9,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
abstract class CoroutineIntentService : BaseService() { abstract class CoroutineIntentService : BaseService() {

View File

@@ -1,101 +0,0 @@
package org.koitharu.kotatsu.core.ui.dialog
import android.content.Context
import android.content.DialogInterface
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemStorageBinding
import org.koitharu.kotatsu.local.data.LocalStorageManager
import java.io.File
class StorageSelectDialog private constructor(private val delegate: AlertDialog) :
DialogInterface by delegate {
fun show() = delegate.show()
class Builder(context: Context, storageManager: LocalStorageManager, listener: OnStorageSelectListener) {
private val adapter = VolumesAdapter(storageManager)
private val delegate = MaterialAlertDialogBuilder(context)
init {
if (adapter.isEmpty) {
delegate.setMessage(R.string.cannot_find_available_storage)
} else {
val defaultValue = runBlocking {
storageManager.getDefaultWriteableDir()
}
adapter.selectedItemPosition = adapter.volumes.indexOfFirst {
it.first.canonicalPath == defaultValue?.canonicalPath
}
delegate.setAdapter(adapter) { d, i ->
listener.onStorageSelected(adapter.getItem(i).first)
d.dismiss()
}
}
}
fun setTitle(@StringRes titleResId: Int): Builder {
delegate.setTitle(titleResId)
return this
}
fun setTitle(title: CharSequence): Builder {
delegate.setTitle(title)
return this
}
fun setNegativeButton(@StringRes textId: Int): Builder {
delegate.setNegativeButton(textId, null)
return this
}
fun create() = StorageSelectDialog(delegate.create())
}
private class VolumesAdapter(storageManager: LocalStorageManager) : BaseAdapter() {
var selectedItemPosition: Int = -1
val volumes = getAvailableVolumes(storageManager)
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: LayoutInflater.from(parent.context).inflate(R.layout.item_storage, parent, false)
val binding = (view.tag as? ItemStorageBinding) ?: ItemStorageBinding.bind(view).also {
view.tag = it
}
val item = volumes[position]
binding.imageViewIndicator.isChecked = selectedItemPosition == position
binding.textViewTitle.text = item.second
binding.textViewSubtitle.text = item.first.path
return view
}
override fun getItem(position: Int): Pair<File, String> = volumes[position]
override fun getItemId(position: Int) = position.toLong()
override fun getCount() = volumes.size
override fun hasStableIds() = true
private fun getAvailableVolumes(storageManager: LocalStorageManager): List<Pair<File, String>> {
return runBlocking {
storageManager.getWriteableDirs().map {
it to storageManager.getStorageDisplayName(it)
}
}
}
}
fun interface OnStorageSelectListener {
fun onStorageSelected(file: File)
}
}

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.core.ui.image
import android.graphics.Bitmap
import android.media.ThumbnailUtils
import coil.size.Size
import coil.size.pxOrElse
import coil.transform.Transformation
class ThumbnailTransformation : Transformation {
override val cacheKey: String = javaClass.name
override suspend fun transform(input: Bitmap, size: Size): Bitmap {
return ThumbnailUtils.extractThumbnail(
input,
size.width.pxOrElse { input.width },
size.height.pxOrElse { input.height },
)
}
override fun equals(other: Any?) = other is ThumbnailTransformation
override fun hashCode() = javaClass.hashCode()
}

View File

@@ -9,6 +9,7 @@ import android.util.AttributeSet
import android.util.TypedValue import android.util.TypedValue
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.* import android.widget.*
import androidx.annotation.* import androidx.annotation.*
@@ -24,6 +25,7 @@ import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isLayoutReversed import org.koitharu.kotatsu.core.util.ext.isLayoutReversed
import org.koitharu.kotatsu.core.util.ext.parents
import org.koitharu.kotatsu.databinding.FastScrollerBinding import org.koitharu.kotatsu.databinding.FastScrollerBinding
import kotlin.math.roundToInt import kotlin.math.roundToInt
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@@ -115,6 +117,9 @@ class FastScroller @JvmOverloads constructor(
return viewHeight * proportion return viewHeight * proportion
} }
val isScrollbarVisible: Boolean
get() = binding.scrollbar.isVisible
init { init {
clipChildren = false clipChildren = false
orientation = HORIZONTAL orientation = HORIZONTAL
@@ -165,7 +170,9 @@ class FastScroller @JvmOverloads constructor(
when (event.actionMasked) { when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
if (event.x.toInt() !in binding.scrollbar.left..binding.scrollbar.right) return false if (!isScrollbarVisible || event.x.toInt() !in binding.scrollbar.left..binding.scrollbar.right) {
return false
}
requestDisallowInterceptTouchEvent(true) requestDisallowInterceptTouchEvent(true)
setHandleSelected(true) setHandleSelected(true)
@@ -296,10 +303,12 @@ class FastScroller @JvmOverloads constructor(
if (parent is ViewGroup) { if (parent is ViewGroup) {
setLayoutParams(parent as ViewGroup) setLayoutParams(parent as ViewGroup)
} else if (recyclerView.parent is ViewGroup) { } else {
val viewGroup = recyclerView.parent as ViewGroup val viewGroup = findValidParent(recyclerView)
viewGroup.addView(this) if (viewGroup != null) {
setLayoutParams(viewGroup) viewGroup.addView(this)
setLayoutParams(viewGroup)
}
} }
recyclerView.addOnScrollListener(scrollListener) recyclerView.addOnScrollListener(scrollListener)
@@ -513,6 +522,14 @@ class FastScroller @JvmOverloads constructor(
return BubbleSize.values().getOrNull(ordinal) ?: defaultValue return BubbleSize.values().getOrNull(ordinal) ?: defaultValue
} }
private fun findValidParent(view: View): ViewGroup? = view.parents.firstNotNullOfOrNull { p ->
if (p is FrameLayout || p is ConstraintLayout || p is CoordinatorLayout || p is RelativeLayout) {
p as ViewGroup
} else {
null
}
}
private val BubbleSize.textSize private val BubbleSize.textSize
@Px get() = resources.getDimension(textSizeId) @Px get() = resources.getDimension(textSizeId)

View File

@@ -6,7 +6,6 @@ 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.ComponentDialog
import androidx.activity.OnBackPressedDispatcher import androidx.activity.OnBackPressedDispatcher
import androidx.appcompat.app.AppCompatDialogFragment import androidx.appcompat.app.AppCompatDialogFragment
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
@@ -36,7 +35,7 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
get() = behavior?.state == AdaptiveSheetBehavior.STATE_EXPANDED get() = behavior?.state == AdaptiveSheetBehavior.STATE_EXPANDED
val onBackPressedDispatcher: OnBackPressedDispatcher val onBackPressedDispatcher: OnBackPressedDispatcher
get() = (requireDialog() as ComponentDialog).onBackPressedDispatcher get() = requireComponentDialog().onBackPressedDispatcher
final override fun onCreateView( final override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,

View File

@@ -14,7 +14,7 @@ class ActionModeDelegate : OnBackPressedCallback(false) {
get() = activeActionMode != null get() = activeActionMode != null
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
activeActionMode?.finish() finishActionMode()
} }
fun onSupportActionModeStarted(mode: ActionMode) { fun onSupportActionModeStarted(mode: ActionMode) {
@@ -45,6 +45,10 @@ class ActionModeDelegate : OnBackPressedCallback(false) {
owner.lifecycle.addObserver(ListenerLifecycleObserver(listener)) owner.lifecycle.addObserver(ListenerLifecycleObserver(listener))
} }
fun finishActionMode() {
activeActionMode?.finish()
}
private inner class ListenerLifecycleObserver( private inner class ListenerLifecycleObserver(
private val listener: ActionModeListener, private val listener: ActionModeListener,
) : DefaultLifecycleObserver { ) : DefaultLifecycleObserver {

View File

@@ -6,7 +6,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
fun interface ReversibleHandle { fun interface ReversibleHandle {

View File

@@ -5,10 +5,9 @@ import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import java.util.LinkedList
class WindowInsetsDelegate( class WindowInsetsDelegate : OnApplyWindowInsetsListener, View.OnLayoutChangeListener {
private val listener: WindowInsetsListener,
) : OnApplyWindowInsetsListener, View.OnLayoutChangeListener {
@JvmField @JvmField
var handleImeInsets: Boolean = false var handleImeInsets: Boolean = false
@@ -16,6 +15,7 @@ class WindowInsetsDelegate(
@JvmField @JvmField
var interceptingWindowInsetsListener: OnApplyWindowInsetsListener? = null var interceptingWindowInsetsListener: OnApplyWindowInsetsListener? = null
private val listeners = LinkedList<WindowInsetsListener>()
private var lastInsets: Insets? = null private var lastInsets: Insets? = null
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
@@ -29,7 +29,7 @@ class WindowInsetsDelegate(
handledInsets.getInsets(WindowInsetsCompat.Type.systemBars()) handledInsets.getInsets(WindowInsetsCompat.Type.systemBars())
} }
if (newInsets != lastInsets) { if (newInsets != lastInsets) {
listener.onWindowInsetsChanged(newInsets) listeners.forEach { it.onWindowInsetsChanged(newInsets) }
lastInsets = newInsets lastInsets = newInsets
} }
return handledInsets return handledInsets
@@ -52,6 +52,15 @@ class WindowInsetsDelegate(
} }
} }
fun addInsetsListener(listener: WindowInsetsListener) {
listeners.add(listener)
lastInsets?.let { listener.onWindowInsetsChanged(it) }
}
fun removeInsetsListener(listener: WindowInsetsListener) {
listeners.remove(listener)
}
fun onViewCreated(view: View) { fun onViewCreated(view: View) {
ViewCompat.setOnApplyWindowInsetsListener(view, this) ViewCompat.setOnApplyWindowInsetsListener(view, this)
view.addOnLayoutChangeListener(this) view.addOnLayoutChangeListener(this)

View File

@@ -92,6 +92,14 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
) )
} }
fun showOrHide(show: Boolean) {
if (show) {
show()
} else {
hide()
}
}
private fun animateTranslation(targetY: Float, duration: Long, interpolator: TimeInterpolator) { private fun animateTranslation(targetY: Float, duration: Long, interpolator: TimeInterpolator) {
currentAnimator = animate() currentAnimator = animate()
.translationY(targetY) .translationY(targetY)

View File

@@ -24,6 +24,7 @@ import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.shape.ShapeAppearanceModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ViewTwoLinesItemBinding import org.koitharu.kotatsu.databinding.ViewTwoLinesItemBinding
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
@@ -35,6 +36,18 @@ class TwoLinesItemView @JvmOverloads constructor(
private val binding = ViewTwoLinesItemBinding.inflate(LayoutInflater.from(context), this) private val binding = ViewTwoLinesItemBinding.inflate(LayoutInflater.from(context), this)
var title: CharSequence?
get() = binding.title.text
set(value) {
binding.title.text = value
}
var subtitle: CharSequence?
get() = binding.subtitle.textAndVisible
set(value) {
binding.subtitle.textAndVisible = value
}
init { init {
var textColors: ColorStateList? = null var textColors: ColorStateList? = null
context.withStyledAttributes( context.withStyledAttributes(
@@ -76,8 +89,7 @@ class TwoLinesItemView @JvmOverloads constructor(
} }
fun setIconResource(@DrawableRes resId: Int) { fun setIconResource(@DrawableRes resId: Int) {
val icon = if (resId != 0) ContextCompat.getDrawable(context, resId) else null binding.icon.setImageResource(resId)
binding.icon.setImageDrawable(icon)
} }
private fun createShapeDrawable(ta: TypedArray): InsetDrawable { private fun createShapeDrawable(ta: TypedArray): InsetDrawable {

View File

@@ -0,0 +1,60 @@
package org.koitharu.kotatsu.core.util
import android.app.Activity
import android.content.Context
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks
import org.acra.ACRA
import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.WeakHashMap
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AcraScreenLogger @Inject constructor() : FragmentLifecycleCallbacks(), DefaultActivityLifecycleCallbacks {
private val timeFormat = SimpleDateFormat.getTimeInstance(DateFormat.DEFAULT, Locale.ROOT)
private val keys = WeakHashMap<Any, String>()
override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) {
super.onFragmentAttached(fm, f, context)
ACRA.errorReporter.putCustomData(f.key(), f.arguments.contentToString())
}
override fun onFragmentDetached(fm: FragmentManager, f: Fragment) {
super.onFragmentDetached(fm, f)
ACRA.errorReporter.removeCustomData(f.key())
keys.remove(f)
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
super.onActivityCreated(activity, savedInstanceState)
ACRA.errorReporter.putCustomData(activity.key(), activity.intent.extras.contentToString())
(activity as? FragmentActivity)?.supportFragmentManager?.registerFragmentLifecycleCallbacks(this, true)
}
override fun onActivityDestroyed(activity: Activity) {
super.onActivityDestroyed(activity)
ACRA.errorReporter.removeCustomData(activity.key())
keys.remove(activity)
}
private fun Any.key() = keys.getOrPut(this) {
"${time()}: ${javaClass.simpleName}"
}
private fun time() = timeFormat.format(Date())
@Suppress("DEPRECATION")
private fun Bundle?.contentToString() = this?.keySet()?.joinToString { k ->
val v = get(k)
"$k=$v"
} ?: toString()
}

View File

@@ -1,6 +0,0 @@
package org.koitharu.kotatsu.core.util
fun interface BufferedObserver<T> {
fun onChanged(t: T, previous: T?)
}

View File

@@ -5,7 +5,8 @@ import android.app.Activity
class TaggedActivityResult( class TaggedActivityResult(
val tag: String, val tag: String,
val result: Int, val result: Int,
) ) {
val TaggedActivityResult.isSuccess: Boolean val isSuccess: Boolean
get() = this.result == Activity.RESULT_OK get() = result == Activity.RESULT_OK
}

View File

@@ -61,6 +61,10 @@ class WorkManagerHelper(
return workManagerImpl.getWorkInfoById(id).await() return workManagerImpl.getWorkInfoById(id).await()
} }
suspend fun getUniqueWorkInfoByName(name: String): List<WorkInfo> {
return workManagerImpl.getWorkInfosForUniqueWork(name).await().orEmpty()
}
suspend fun updateWork(request: WorkRequest): WorkManager.UpdateResult { suspend fun updateWork(request: WorkRequest): WorkManager.UpdateResult {
return workManagerImpl.updateWork(request).await() return workManagerImpl.updateWork(request).await()
} }

View File

@@ -6,6 +6,7 @@ import android.app.ActivityManager.MemoryInfo
import android.app.ActivityOptions import android.app.ActivityOptions
import android.content.Context import android.content.Context
import android.content.Context.ACTIVITY_SERVICE import android.content.Context.ACTIVITY_SERVICE
import android.content.Context.POWER_SERVICE
import android.content.ContextWrapper import android.content.ContextWrapper
import android.content.OperationApplicationException import android.content.OperationApplicationException
import android.content.SharedPreferences import android.content.SharedPreferences
@@ -16,6 +17,8 @@ import android.database.SQLException
import android.graphics.Color import android.graphics.Color
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings import android.provider.Settings
import android.view.View import android.view.View
import android.view.ViewPropertyAnimator import android.view.ViewPropertyAnimator
@@ -43,7 +46,6 @@ import org.jsoup.internal.StringUtil.StringJoiner
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException import org.xmlpull.v1.XmlPullParserException
import kotlin.math.roundToLong import kotlin.math.roundToLong
@@ -51,6 +53,9 @@ import kotlin.math.roundToLong
val Context.activityManager: ActivityManager? val Context.activityManager: ActivityManager?
get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager
val Context.powerManager: PowerManager?
get() = getSystemService(POWER_SERVICE) as? PowerManager
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this) fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable { suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable {
@@ -141,6 +146,10 @@ fun Context.isLowRamDevice(): Boolean {
return activityManager?.isLowRamDevice ?: false return activityManager?.isLowRamDevice ?: false
} }
fun Context.isPowerSaveMode(): Boolean {
return powerManager?.isPowerSaveMode == true
}
val Context.ramAvailable: Long val Context.ramAvailable: Long
get() { get() {
val result = MemoryInfo() val result = MemoryInfo()
@@ -148,13 +157,17 @@ val Context.ramAvailable: Long
return result.availMem return result.availMem
} }
fun scaleUpActivityOptionsOf(view: View): ActivityOptions = ActivityOptions.makeScaleUpAnimation( fun scaleUpActivityOptionsOf(view: View): Bundle? = if (view.context.isAnimationsEnabled) {
view, ActivityOptions.makeScaleUpAnimation(
0, view,
0, 0,
view.width, 0,
view.height, view.width,
) view.height,
).toBundle()
} else {
null
}
fun Resources.getLocalesConfig(): LocaleListCompat { fun Resources.getLocalesConfig(): LocaleListCompat {
val tagsList = StringJoiner(",") val tagsList = StringJoiner(",")

View File

@@ -9,8 +9,8 @@ import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import androidx.core.content.IntentCompat import androidx.core.content.IntentCompat
import androidx.core.os.BundleCompat import androidx.core.os.BundleCompat
import androidx.core.os.ParcelCompat
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags
import java.io.Serializable import java.io.Serializable
// https://issuetracker.google.com/issues/240585930 // https://issuetracker.google.com/issues/240585930
@@ -36,11 +36,11 @@ inline fun <reified T : Serializable> Bundle.getSerializableCompat(key: String):
} }
inline fun <reified T : Parcelable> Parcel.readParcelableCompat(): T? { inline fun <reified T : Parcelable> Parcel.readParcelableCompat(): T? {
return readParcelable(ParcelableMangaTags::class.java.classLoader) as T? return ParcelCompat.readParcelable(this, T::class.java.classLoader, T::class.java)
} }
inline fun <reified T : Serializable> Parcel.readSerializableCompat(): T? { inline fun <reified T : Serializable> Parcel.readSerializableCompat(): T? {
return readSerializable() as T? return ParcelCompat.readSerializable(this, T::class.java.classLoader, T::class.java)
} }
inline fun <reified T : Serializable> Bundle.requireSerializable(key: String): T { inline fun <reified T : Serializable> Bundle.requireSerializable(key: String): T {
@@ -49,12 +49,6 @@ inline fun <reified T : Serializable> Bundle.requireSerializable(key: String): T
} }
} }
inline fun <reified T : Parcelable> Bundle.requireParcelable(key: String): T {
return checkNotNull(getParcelableCompat(key)) {
"Parcelable of type \"${T::class.java.name}\" not found at \"$key\""
}
}
fun <T> SavedStateHandle.require(key: String): T { fun <T> SavedStateHandle.require(key: String): T {
return checkNotNull(get(key)) { return checkNotNull(get(key)) {
"Value $key not found in SavedStateHandle or has a wrong type" "Value $key not found in SavedStateHandle or has a wrong type"

View File

@@ -0,0 +1,90 @@
package org.koitharu.kotatsu.core.util.ext
import android.annotation.TargetApi
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.storage.StorageManager
import android.provider.DocumentsContract
import org.koitharu.kotatsu.parsers.util.removeSuffix
import java.io.File
import java.lang.reflect.Array as ArrayReflect
private const val PRIMARY_VOLUME_NAME = "primary"
fun Uri.resolveFile(context: Context): File? {
val volumeId = getVolumeIdFromTreeUri(this) ?: return null
val volumePath = getVolumePath(volumeId, context)?.removeSuffix(File.separatorChar) ?: return null
val documentPath = getDocumentPathFromTreeUri(this)?.removeSuffix(File.separatorChar) ?: return null
return File(
if (documentPath.isNotEmpty()) {
if (documentPath.startsWith(File.separator)) {
volumePath + documentPath
} else {
volumePath + File.separator + documentPath
}
} else {
volumePath
},
)
}
private fun getVolumePath(volumeId: String, context: Context): String? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
getVolumePathForAndroid11AndAbove(volumeId, context)
} else {
getVolumePathBeforeAndroid11(volumeId, context)
}
}
private fun getVolumePathBeforeAndroid11(volumeId: String, context: Context): String? = runCatching {
val mStorageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
val storageVolumeClazz = Class.forName("android.os.storage.StorageVolume")
val getVolumeList = mStorageManager.javaClass.getMethod("getVolumeList")
val getUuid = storageVolumeClazz.getMethod("getUuid")
val getPath = storageVolumeClazz.getMethod("getPath")
val isPrimary = storageVolumeClazz.getMethod("isPrimary")
val result = getVolumeList.invoke(mStorageManager)
val length = ArrayReflect.getLength(checkNotNull(result))
(0 until length).firstNotNullOfOrNull { i ->
val storageVolumeElement = ArrayReflect.get(result, i)
val uuid = getUuid.invoke(storageVolumeElement) as String
val primary = isPrimary.invoke(storageVolumeElement) as Boolean
when {
primary && volumeId == PRIMARY_VOLUME_NAME -> getPath.invoke(storageVolumeElement) as String
uuid == volumeId -> getPath.invoke(storageVolumeElement) as String
else -> null
}
}
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
@TargetApi(Build.VERSION_CODES.R)
private fun getVolumePathForAndroid11AndAbove(volumeId: String, context: Context): String? = runCatching {
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
storageManager.storageVolumes.firstNotNullOfOrNull { volume ->
if (volume.isPrimary && volumeId == PRIMARY_VOLUME_NAME) {
volume.directory?.path
} else {
val uuid = volume.uuid
if (uuid != null && uuid == volumeId) volume.directory?.path else null
}
}
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
private fun getVolumeIdFromTreeUri(treeUri: Uri): String? {
val docId = DocumentsContract.getTreeDocumentId(treeUri)
val split = docId.split(":".toRegex())
return split.firstOrNull()?.takeUnless { it.isEmpty() }
}
private fun getDocumentPathFromTreeUri(treeUri: Uri): String? {
val docId = DocumentsContract.getTreeDocumentId(treeUri)
val split: Array<String?> = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
return if (split.size >= 2 && split[1] != null) split[1] else File.separator
}

View File

@@ -26,6 +26,8 @@ fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() }
fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() } fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() }
fun File.isNotEmpty() = length() != 0L
fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().use { fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().use {
it.readText() it.readText()
} }

View File

@@ -4,6 +4,7 @@ import android.os.SystemClock
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow 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
@@ -62,3 +63,23 @@ fun <T> Flow<T>.zipWithPrevious(): Flow<Pair<T?, T>> = flow {
emit(result) emit(result)
} }
} }
@Suppress("UNCHECKED_CAST")
fun <T1, T2, T3, T4, T5, T6, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
transform: suspend (T1, T2, T3, T4, T5, T6) -> R
): Flow<R> = combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
args[3] as T4,
args[4] as T5,
args[5] as T6,
)
}

View File

@@ -14,7 +14,13 @@ import org.koitharu.kotatsu.core.util.Event
fun <T> Flow<T>.observe(owner: LifecycleOwner, collector: FlowCollector<T>) { fun <T> Flow<T>.observe(owner: LifecycleOwner, collector: FlowCollector<T>) {
val start = if (this is StateFlow) CoroutineStart.UNDISPATCHED else CoroutineStart.DEFAULT val start = if (this is StateFlow) CoroutineStart.UNDISPATCHED else CoroutineStart.DEFAULT
owner.lifecycleScope.launch(start = start) { owner.lifecycleScope.launch(start = start) {
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { collect(collector)
}
}
fun <T> Flow<T>.observe(owner: LifecycleOwner, minState: Lifecycle.State, collector: FlowCollector<T>) {
owner.lifecycleScope.launch {
owner.lifecycle.repeatOnLifecycle(minState) {
collect(collector) collect(collector)
} }
} }

View File

@@ -52,3 +52,11 @@ suspend fun Fragment.awaitViewLifecycle(): LifecycleOwner {
} }
} }
} }
fun DialogFragment.showDistinct(fm: FragmentManager, tag: String) {
val existing = fm.findFragmentByTag(tag) as? DialogFragment?
if (existing != null && existing.isVisible && existing.arguments == this.arguments) {
return
}
show(fm, tag)
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import okhttp3.Cookie
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
@@ -38,3 +39,23 @@ fun Response.ensureSuccess() = apply {
throw IllegalStateException(message) throw IllegalStateException(message)
} }
} }
fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
c.name(name)
c.value(value)
if (persistent) {
c.expiresAt(expiresAt)
}
if (hostOnly) {
c.hostOnlyDomain(domain)
} else {
c.domain(domain)
}
c.path(path)
if (secure) {
c.secure()
}
if (httpOnly) {
c.httpOnly()
}
}

View File

@@ -2,10 +2,9 @@ package org.koitharu.kotatsu.core.util.ext
import androidx.annotation.FloatRange import androidx.annotation.FloatRange
import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.util.UUID import java.util.UUID
inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String { inline fun <C : CharSequence> C?.ifNullOrEmpty(defaultValue: () -> C): C {
return if (this.isNullOrEmpty()) defaultValue() else this return if (this.isNullOrEmpty()) defaultValue() else this
} }
@@ -35,3 +34,9 @@ fun String.almostEquals(other: String, @FloatRange(from = 0.0) threshold: Float)
val diff = lowercase().levenshteinDistance(other.lowercase()) / ((length + other.length) / 2f) val diff = lowercase().levenshteinDistance(other.lowercase()) / ((length + other.length) / 2f)
return diff < threshold return diff < threshold
} }
fun CharSequence.sanitize(): CharSequence {
return filterNot { c -> c.isReplacement() }
}
fun Char.isReplacement() = this in '\uFFF0'..'\uFFFF'

View File

@@ -34,6 +34,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported) is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
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 EmptyHistoryException -> resources.getString(R.string.history_is_empty) is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
is SyncApiException, is SyncApiException,
is ContentUnavailableException, is ContentUnavailableException,
@@ -87,3 +88,6 @@ fun Throwable.isWebViewUnavailable(): Boolean {
return (this is AndroidRuntimeException && message?.contains("WebView") == true) || return (this is AndroidRuntimeException && message?.contains("WebView") == true) ||
cause?.isWebViewUnavailable() == true cause?.isWebViewUnavailable() == true
} }
@Suppress("FunctionName")
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.core.util.ext
import androidx.annotation.DrawableRes
import androidx.appcompat.widget.Toolbar
fun Toolbar.setNavigationIconSafe(@DrawableRes iconRes: Int, retry: Boolean = true) {
try {
setNavigationIcon(iconRes)
} catch (e: IllegalStateException) {
if (retry) {
post { setNavigationIconSafe(iconRes, retry = false) }
}
}
}

View File

@@ -10,12 +10,13 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.CoroutineIntentService import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -115,6 +116,9 @@ class MangaPrefetchService : CoroutineIntentService() {
if (source == MangaSource.LOCAL) { if (source == MangaSource.LOCAL) {
return false return false
} }
if (context.isPowerSaveMode()) {
return false
}
val entryPoint = EntryPointAccessors.fromApplication(context, PrefetchCompanionEntryPoint::class.java) val entryPoint = EntryPointAccessors.fromApplication(context, PrefetchCompanionEntryPoint::class.java)
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled
} }

View File

@@ -0,0 +1,92 @@
package org.koitharu.kotatsu.details.ui
import android.transition.TransitionManager
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.graphics.Insets
import androidx.core.view.setMargins
import androidx.core.view.updateLayoutParams
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.getThemeDimensionPixelSize
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.databinding.ItemTipBinding
import com.google.android.material.R as materialR
class ButtonTip(
private val root: ViewGroup,
private val insetsDelegate: WindowInsetsDelegate,
private val viewModel: DetailsViewModel,
) : View.OnClickListener, WindowInsetsDelegate.WindowInsetsListener {
private var selfBinding = ItemTipBinding.inflate(LayoutInflater.from(root.context), root, false)
private val actionBarSize = root.context.getThemeDimensionPixelSize(materialR.attr.actionBarSize)
init {
selfBinding.textView.setText(R.string.details_button_tip)
selfBinding.imageViewIcon.setImageResource(R.drawable.ic_tap)
selfBinding.root.id = R.id.layout_tip
selfBinding.buttonClose.setOnClickListener(this)
}
override fun onClick(v: View?) {
remove()
}
override fun onWindowInsetsChanged(insets: Insets) {
if (root is CoordinatorLayout) {
selfBinding.root.updateLayoutParams<CoordinatorLayout.LayoutParams> {
bottomMargin = topMargin + insets.bottom + insets.top + actionBarSize
}
}
}
fun addToRoot() {
val lp: ViewGroup.LayoutParams = when (root) {
is CoordinatorLayout -> CoordinatorLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).apply {
// anchorId = R.id.layout_bottom
// anchorGravity = Gravity.TOP
gravity = Gravity.BOTTOM
setMargins(root.resources.getDimensionPixelOffset(R.dimen.margin_normal))
bottomMargin += actionBarSize
}
is ConstraintLayout -> ConstraintLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).apply {
width = root.resources.getDimensionPixelSize(R.dimen.m3_side_sheet_width)
setMargins(root.resources.getDimensionPixelOffset(R.dimen.margin_normal))
}
else -> ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
root.addView(selfBinding.root, lp)
if (root is ConstraintLayout) {
val cs = ConstraintSet()
cs.clone(root)
cs.connect(R.id.layout_tip, ConstraintSet.TOP, R.id.appbar, ConstraintSet.BOTTOM)
cs.connect(R.id.layout_tip, ConstraintSet.START, R.id.card_chapters, ConstraintSet.START)
cs.connect(R.id.layout_tip, ConstraintSet.END, R.id.card_chapters, ConstraintSet.END)
cs.applyTo(root)
}
insetsDelegate.addInsetsListener(this)
}
fun remove() {
if (root.context.isAnimationsEnabled) {
TransitionManager.beginDelayedTransition(root)
}
insetsDelegate.removeInsetsListener(this)
root.removeView(selfBinding.root)
viewModel.onButtonTipClosed()
}
}

View File

@@ -30,6 +30,7 @@ class ChaptersBottomSheetMediator(
} }
override fun onActionModeStarted(mode: ActionMode) { override fun onActionModeStarted(mode: ActionMode) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
lock() lock()
} }

View File

@@ -18,6 +18,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.util.RecyclerViewScrollCallback import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
@@ -25,7 +26,7 @@ import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -66,6 +67,9 @@ class ChaptersFragment :
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) { viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
binding.textViewHolder.isVisible = it binding.textViewHolder.isVisible = it
} }
viewModel.onSelectChapter.observeEvent(viewLifecycleOwner) {
selectionController?.onItemLongClick(it)
}
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -79,12 +83,11 @@ class ChaptersFragment :
return return
} }
startActivity( startActivity(
ReaderActivity.newIntent( IntentBuilder(view.context)
context = view.context, .manga(viewModel.manga.value ?: return)
manga = viewModel.manga.value ?: return, .state(ReaderState(item.chapter.id, 0, 0))
state = ReaderState(item.chapter.id, 0, 0), .build(),
), scaleUpActivityOptionsOf(view),
scaleUpActivityOptionsOf(view).toBundle(),
) )
} }

View File

@@ -30,7 +30,7 @@ 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.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.ShortcutsUpdater import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
@@ -43,6 +43,7 @@ import org.koitharu.kotatsu.core.util.ext.measureHeight
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.core.util.ext.setNavigationIconSafe
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.service.MangaPrefetchService
@@ -53,8 +54,9 @@ import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
import java.lang.ref.WeakReference
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@@ -67,9 +69,10 @@ class DetailsActivity :
PopupMenu.OnMenuItemClickListener { PopupMenu.OnMenuItemClickListener {
@Inject @Inject
lateinit var shortcutsUpdater: ShortcutsUpdater lateinit var appShortcutManager: AppShortcutManager
private lateinit var viewBadge: ViewBadge private lateinit var viewBadge: ViewBadge
private var buttonTip: WeakReference<ButtonTip>? = null
private val viewModel: DetailsViewModel by viewModels() private val viewModel: DetailsViewModel by viewModels()
private lateinit var chaptersMenuProvider: ChaptersMenuProvider private lateinit var chaptersMenuProvider: ChaptersMenuProvider
@@ -122,6 +125,7 @@ class DetailsActivity :
viewModel.onShowToast.observeEvent(this) { viewModel.onShowToast.observeEvent(this) {
makeSnackbar(getString(it), Snackbar.LENGTH_SHORT).show() makeSnackbar(getString(it), Snackbar.LENGTH_SHORT).show()
} }
viewModel.onShowTip.observeEvent(this) { showTip() }
viewModel.historyInfo.observe(this, ::onHistoryChanged) viewModel.historyInfo.observe(this, ::onHistoryChanged)
viewModel.selectedBranch.observe(this) { viewModel.selectedBranch.observe(this) {
viewBinding.toolbarChapters?.subtitle = it viewBinding.toolbarChapters?.subtitle = it
@@ -144,7 +148,7 @@ class DetailsActivity :
activity = this, activity = this,
viewModel = viewModel, viewModel = viewModel,
snackbarHost = viewBinding.containerChapters, snackbarHost = viewBinding.containerChapters,
shortcutsUpdater = shortcutsUpdater, appShortcutManager = appShortcutManager,
), ),
) )
} }
@@ -162,6 +166,8 @@ class DetailsActivity :
override fun onLongClick(v: View): Boolean = when (v.id) { override fun onLongClick(v: View): Boolean = when (v.id) {
R.id.button_read -> { R.id.button_read -> {
buttonTip?.get()?.remove()
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.setOnMenuItemClickListener(this) menu.setOnMenuItemClickListener(this)
@@ -206,7 +212,7 @@ class DetailsActivity :
} }
if (isExpanded) { if (isExpanded) {
toolbar.addMenuProvider(chaptersMenuProvider) toolbar.addMenuProvider(chaptersMenuProvider)
toolbar.setNavigationIcon(materialR.drawable.abc_ic_clear_material) toolbar.setNavigationIconSafe(materialR.drawable.abc_ic_clear_material)
} else { } else {
toolbar.removeMenuProvider(chaptersMenuProvider) toolbar.removeMenuProvider(chaptersMenuProvider)
toolbar.navigationIcon = null toolbar.navigationIcon = null
@@ -244,7 +250,7 @@ class DetailsActivity :
bottomMargin = insets.bottom + marginEnd bottomMargin = insets.bottom + marginEnd
} }
viewBinding.dragHandle?.updateLayoutParams<MarginLayoutParams> { viewBinding.dragHandle?.updateLayoutParams<MarginLayoutParams> {
bottomMargin = insets.bottom bottomMargin = insets.top
} }
} }
@@ -296,12 +302,11 @@ class DetailsActivity :
snackbar.show() snackbar.show()
} else { } else {
startActivity( startActivity(
ReaderActivity.newIntent( IntentBuilder(this)
context = this, .manga(manga)
manga = manga, .branch(viewModel.selectedBranchValue)
branch = viewModel.selectedBranchValue, .incognito(isIncognitoMode)
isIncognitoMode = isIncognitoMode, .build(),
),
) )
if (isIncognitoMode) { if (isIncognitoMode) {
Toast.makeText(this, R.string.incognito_mode, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
@@ -345,8 +350,16 @@ class DetailsActivity :
} }
} }
private fun showTip() {
val tip = ButtonTip(viewBinding.root as ViewGroup, insetsDelegate, viewModel)
tip.addToRoot()
buttonTip = WeakReference(tip)
}
companion object { companion object {
const val TIP_BUTTON = "btn_read"
fun newIntent(context: Context, manga: Manga): Intent { fun newIntent(context: Context, manga: Manga): Intent {
return Intent(context, DetailsActivity::class.java) return Intent(context, DetailsActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true)) .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))

View File

@@ -23,7 +23,6 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.core.model.countChaptersByBranch import org.koitharu.kotatsu.core.model.countChaptersByBranch
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
@@ -45,6 +44,7 @@ import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration
import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
import org.koitharu.kotatsu.history.data.PROGRESS_NONE import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.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
@@ -67,7 +67,7 @@ class DetailsFragment :
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
@Inject @Inject
lateinit var tagHighlighter: MangaTagHighlighter lateinit var tagHighlighter: ListExtraProvider
private val viewModel by activityViewModels<DetailsViewModel>() private val viewModel by activityViewModels<DetailsViewModel>()
@@ -96,8 +96,8 @@ class DetailsFragment :
override fun onItemClick(item: Bookmark, view: View) { override fun onItemClick(item: Bookmark, view: View) {
startActivity( startActivity(
ReaderActivity.newIntent(view.context, item), ReaderActivity.IntentBuilder(view.context).bookmark(item).incognito(true).build(),
scaleUpActivityOptionsOf(view).toBundle(), scaleUpActivityOptionsOf(view),
) )
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show() Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
} }
@@ -257,7 +257,7 @@ class DetailsFragment :
manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl },
manga.source, manga.source,
), ),
scaleUpActivityOptionsOf(v).toBundle(), scaleUpActivityOptionsOf(v),
) )
} }
} }
@@ -283,7 +283,7 @@ class DetailsFragment :
manga.tags.map { tag -> manga.tags.map { tag ->
ChipsView.ChipModel( ChipsView.ChipModel(
title = tag.title, title = tag.title,
tint = tagHighlighter.getTint(tag), tint = tagHighlighter.getTagTint(tag),
icon = 0, icon = 0,
data = tag, data = tag,
isCheckable = false, isCheckable = false,

View File

@@ -15,13 +15,12 @@ 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.browser.BrowserActivity import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.core.os.ShortcutsUpdater import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.details.ui.model.MangaBranch import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
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
@@ -29,8 +28,8 @@ class DetailsMenuProvider(
private val activity: FragmentActivity, private val activity: FragmentActivity,
private val viewModel: DetailsViewModel, private val viewModel: DetailsViewModel,
private val snackbarHost: View, private val snackbarHost: View,
private val shortcutsUpdater: ShortcutsUpdater, private val appShortcutManager: AppShortcutManager,
) : MenuProvider { ) : MenuProvider, OnListItemClickListener<DownloadOption> {
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)
@@ -44,7 +43,7 @@ class DetailsMenuProvider(
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_favourite).setIcon( menu.findItem(R.id.action_favourite).setIcon(
if (viewModel.favouriteCategories.value == true) R.drawable.ic_heart else R.drawable.ic_heart_outline, if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline,
) )
} }
@@ -80,15 +79,7 @@ class DetailsMenuProvider(
} }
R.id.action_save -> { R.id.action_save -> {
viewModel.manga.value?.let { DownloadDialogHelper(snackbarHost, viewModel).show(this)
val chaptersCount = it.chapters?.size ?: 0
val branches = viewModel.branches.value.orEmpty()
if (chaptersCount > 5 || branches.size > 1) {
showSaveConfirmation(it, chaptersCount, branches)
} else {
viewModel.download(null)
}
}
} }
R.id.action_browser -> { R.id.action_browser -> {
@@ -112,7 +103,7 @@ class DetailsMenuProvider(
R.id.action_shortcut -> { R.id.action_shortcut -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
activity.lifecycleScope.launch { activity.lifecycleScope.launch {
if (!shortcutsUpdater.requestPinShortcut(it)) { if (!appShortcutManager.requestPinShortcut(it)) {
Snackbar.make(snackbarHost, R.string.operation_not_supported, Snackbar.LENGTH_SHORT) Snackbar.make(snackbarHost, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
.show() .show()
} }
@@ -125,35 +116,16 @@ class DetailsMenuProvider(
return true return true
} }
private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List<MangaBranch>) { override fun onItemClick(item: DownloadOption, view: View) {
val dialogBuilder = MaterialAlertDialogBuilder(activity) val chaptersIds: Set<Long>? = when (item) {
.setTitle(R.string.save_manga) is DownloadOption.WholeManga -> null
.setNegativeButton(android.R.string.cancel, null) is DownloadOption.SelectionHint -> {
if (branches.size > 1) { viewModel.startChaptersSelection()
val items = Array(branches.size) { i -> branches[i].name.orEmpty() } return
val currentBranch = branches.indexOfFirst { it.isSelected }
val checkedIndices = BooleanArray(branches.size) { i -> i == currentBranch }
dialogBuilder.setMultiChoiceItems(items, checkedIndices) { _, i, checked ->
checkedIndices[i] = checked
}.setPositiveButton(R.string.save) { _, _ ->
val selectedBranches = branches.mapIndexedNotNullTo(HashSet()) { i, b ->
if (checkedIndices[i]) b.name else null
}
val chaptersIds = manga.chapters?.mapNotNullToSet { c ->
if (c.branch in selectedBranches) c.id else null
}
viewModel.download(chaptersIds)
}
} else {
dialogBuilder.setMessage(
activity.getString(
R.string.large_manga_save_confirm,
activity.resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount),
),
).setPositiveButton(R.string.save) { _, _ ->
viewModel.download(null)
} }
else -> item.chaptersIds
} }
dialogBuilder.show() viewModel.download(chaptersIds)
} }
} }

View File

@@ -18,6 +18,8 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.first
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
@@ -29,14 +31,17 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.getPreferredBranch import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.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.requireValue import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.details.domain.BranchComparator import org.koitharu.kotatsu.details.domain.BranchComparator
import org.koitharu.kotatsu.details.domain.DetailsInteractor import org.koitharu.kotatsu.details.domain.DetailsInteractor
@@ -69,6 +74,7 @@ class DetailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
private val doubleMangaLoadUseCase: DoubleMangaLoadUseCase, private val doubleMangaLoadUseCase: DoubleMangaLoadUseCase,
networkState: NetworkState,
) : BaseViewModel() { ) : BaseViewModel() {
private val intent = MangaIntent(savedStateHandle) private val intent = MangaIntent(savedStateHandle)
@@ -77,6 +83,8 @@ class DetailsViewModel @Inject constructor(
private var loadingJob: Job private var loadingJob: Job
val onShowToast = MutableEventFlow<Int>() val onShowToast = MutableEventFlow<Int>()
val onShowTip = MutableEventFlow<Unit>()
val onSelectChapter = MutableEventFlow<Long>()
val onDownloadStarted = MutableEventFlow<Unit>() val onDownloadStarted = MutableEventFlow<Unit>()
val manga = doubleManga.map { it?.any } val manga = doubleManga.map { it?.any }
@@ -135,7 +143,7 @@ class DetailsViewModel @Inject constructor(
if (description.isNullOrEmpty()) { if (description.isNullOrEmpty()) {
emit(null) emit(null)
} else { } else {
emit(description.parseAsHtml().filterSpans()) emit(description.parseAsHtml().filterSpans().sanitize())
emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans()) emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans())
} }
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), null) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), null)
@@ -172,8 +180,9 @@ class DetailsViewModel @Inject constructor(
selectedBranch, selectedBranch,
newChaptersCount, newChaptersCount,
bookmarks, bookmarks,
) { manga, history, branch, news, bookmarks -> networkState,
mapChapters(manga?.remote, manga?.local, history, news, branch, bookmarks) ) { manga, history, branch, news, bookmarks, isOnline ->
mapChapters(manga?.remote?.takeIf { isOnline }, manga?.local, history, news, branch, bookmarks)
}, },
isChaptersReversed, isChaptersReversed,
chaptersQuery, chaptersQuery,
@@ -190,6 +199,12 @@ class DetailsViewModel @Inject constructor(
localStorageChanges localStorageChanges
.collect { onDownloadComplete(it) } .collect { onDownloadComplete(it) }
} }
launchJob(Dispatchers.Default) {
if (settings.isTipEnabled(DetailsActivity.TIP_BUTTON)) {
manga.filterNot { it?.chapters.isNullOrEmpty() }.first()
onShowTip.call(Unit)
}
}
} }
fun reload() { fun reload() {
@@ -276,6 +291,18 @@ class DetailsViewModel @Inject constructor(
} }
} }
fun startChaptersSelection() {
val chapters = chapters.value
val chapter = chapters.find {
it.isUnread && !it.isDownloaded
} ?: chapters.firstOrNull() ?: return
onSelectChapter.call(chapter.chapter.id)
}
fun onButtonTipClosed() {
settings.closeTip(DetailsActivity.TIP_BUTTON)
}
private fun doLoad() = launchLoadingJob(Dispatchers.Default) { private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
val result = doubleMangaLoadUseCase(intent) val result = doubleMangaLoadUseCase(intent)
val manga = result.requireAny() val manga = result.requireAny()

View File

@@ -0,0 +1,64 @@
package org.koitharu.kotatsu.details.ui
import android.content.DialogInterface
import android.view.View
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.download.ui.dialog.downloadOptionAD
class DownloadDialogHelper(
private val host: View,
private val viewModel: DetailsViewModel,
) {
fun show(callback: OnListItemClickListener<DownloadOption>) {
val branch = viewModel.selectedBranchValue
val allChapters = viewModel.manga.value?.chapters ?: return
val branchChapters = viewModel.manga.value?.getChapters(branch).orEmpty()
val history = viewModel.history.value
val options = buildList {
add(DownloadOption.WholeManga(allChapters.ids()))
if (branch != null && branchChapters.isNotEmpty()) {
add(DownloadOption.AllChapters(branch, branchChapters.ids()))
}
if (history != null) {
val unreadChapters = branchChapters.takeLastWhile { it.id != history.chapterId }
if (unreadChapters.isNotEmpty() && unreadChapters.size < branchChapters.size) {
add(DownloadOption.AllUnreadChapters(unreadChapters.ids(), branch))
if (unreadChapters.size > 5) {
add(DownloadOption.NextUnreadChapters(unreadChapters.take(5).ids()))
if (unreadChapters.size > 10) {
add(DownloadOption.NextUnreadChapters(unreadChapters.take(10).ids()))
}
}
}
} else {
if (branchChapters.size > 5) {
add(DownloadOption.FirstChapters(branchChapters.take(5).ids()))
if (branchChapters.size > 10) {
add(DownloadOption.FirstChapters(branchChapters.take(10).ids()))
}
}
}
add(DownloadOption.SelectionHint())
}
var dialog: DialogInterface? = null
val listener = OnListItemClickListener<DownloadOption> { item, _ ->
callback.onItemClick(item, host)
dialog?.dismiss()
}
dialog = RecyclerViewAlertDialog.Builder<DownloadOption>(host.context)
.addAdapterDelegate(downloadOptionAD(listener))
.setCancelable(true)
.setTitle(R.string.download)
.setNegativeButton(android.R.string.cancel)
.setItems(options)
.create()
.also { it.show() }
}
}

View File

@@ -1,16 +0,0 @@
package org.koitharu.kotatsu.details.ui
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
class MangaDetailsAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) {
override fun getItemCount() = 2
override fun createFragment(position: Int): Fragment = when (position) {
0 -> DetailsFragment()
1 -> ChaptersFragment()
else -> throw IndexOutOfBoundsException("No fragment for position $position")
}
}

View File

@@ -23,6 +23,7 @@ import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetScrobblingBinding import org.koitharu.kotatsu.databinding.SheetScrobblingBinding
@@ -109,7 +110,7 @@ class ScrobblingInfoSheet :
R.id.imageView_cover -> { R.id.imageView_cover -> {
val coverUrl = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.coverUrl ?: return val coverUrl = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.coverUrl ?: return
val options = scaleUpActivityOptionsOf(v) val options = scaleUpActivityOptionsOf(v)
startActivity(ImageActivity.newIntent(v.context, coverUrl, null), options.toBundle()) startActivity(ImageActivity.newIntent(v.context, coverUrl, null), options)
} }
} }
} }
@@ -122,7 +123,7 @@ class ScrobblingInfoSheet :
} }
requireViewBinding().textViewTitle.text = scrobbling.title requireViewBinding().textViewTitle.text = scrobbling.title
requireViewBinding().ratingBar.rating = scrobbling.rating * requireViewBinding().ratingBar.numStars requireViewBinding().ratingBar.rating = scrobbling.rating * requireViewBinding().ratingBar.numStars
requireViewBinding().textViewDescription.text = scrobbling.description requireViewBinding().textViewDescription.text = scrobbling.description?.sanitize()
requireViewBinding().spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1) requireViewBinding().spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1)
requireViewBinding().imageViewLogo.contentDescription = getString(scrobbling.scrobbler.titleResId) requireViewBinding().imageViewLogo.contentDescription = getString(scrobbling.scrobbler.titleResId)
requireViewBinding().imageViewLogo.setImageResource(scrobbling.scrobbler.iconResId) requireViewBinding().imageViewLogo.setImageResource(scrobbling.scrobbler.iconResId)

View File

@@ -0,0 +1,99 @@
package org.koitharu.kotatsu.download.ui.dialog
import android.content.res.Resources
import androidx.annotation.DrawableRes
import org.koitharu.kotatsu.R
import java.util.Locale
import com.google.android.material.R as materialR
sealed interface DownloadOption {
val chaptersIds: Set<Long>
@get:DrawableRes
val iconResId: Int
val chaptersCount: Int
get() = chaptersIds.size
fun getLabel(resources: Resources): CharSequence
class AllChapters(
val branch: String,
override val chaptersIds: Set<Long>,
) : DownloadOption {
override val iconResId = R.drawable.ic_select_group
override fun getLabel(resources: Resources): CharSequence {
return resources.getString(R.string.download_option_all_chapters, branch)
}
}
class WholeManga(
override val chaptersIds: Set<Long>,
) : DownloadOption {
override val iconResId = materialR.drawable.abc_ic_menu_selectall_mtrl_alpha
override fun getLabel(resources: Resources): CharSequence {
return resources.getString(R.string.download_option_whole_manga)
}
}
class FirstChapters(
override val chaptersIds: Set<Long>,
) : DownloadOption {
override val iconResId = R.drawable.ic_list_start
override fun getLabel(resources: Resources): CharSequence {
return resources.getString(
R.string.download_option_first_n_chapters,
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
.lowercase(Locale.getDefault()),
)
}
}
class AllUnreadChapters(
override val chaptersIds: Set<Long>,
val branch: String?,
) : DownloadOption {
override val iconResId = R.drawable.ic_list_end
override fun getLabel(resources: Resources): CharSequence {
return if (branch == null) {
resources.getString(R.string.download_option_all_unread)
} else {
resources.getString(R.string.download_option_all_unread_b, branch)
}
}
}
class NextUnreadChapters(
override val chaptersIds: Set<Long>,
) : DownloadOption {
override val iconResId = R.drawable.ic_list_next
override fun getLabel(resources: Resources): CharSequence {
return resources.getString(
R.string.download_option_next_unread_n_chapters,
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
.lowercase(Locale.getDefault()),
)
}
}
class SelectionHint : DownloadOption {
override val chaptersIds: Set<Long> = emptySet()
override val iconResId = R.drawable.ic_tap
override fun getLabel(resources: Resources): CharSequence {
return resources.getString(R.string.download_option_manual_selection)
}
}
}

View File

@@ -0,0 +1,27 @@
package org.koitharu.kotatsu.download.ui.dialog
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemDownloadOptionBinding
fun downloadOptionAD(
onClickListener: OnListItemClickListener<DownloadOption>,
) = adapterDelegateViewBinding<DownloadOption, DownloadOption, ItemDownloadOptionBinding>(
{ layoutInflater, parent -> ItemDownloadOptionBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener { v -> onClickListener.onItemClick(item, v) }
bind {
with(binding.root) {
title = item.getLabel(resources)
subtitle = if (item.chaptersCount == 0) null else resources.getQuantityString(
R.plurals.chapters,
item.chaptersCount,
item.chaptersCount,
)
setIconResource(item.iconResId)
}
}
}

View File

@@ -7,6 +7,7 @@ import android.view.MenuItem
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.settings.SettingsActivity
class DownloadsMenuProvider( class DownloadsMenuProvider(
private val context: Context, private val context: Context,
@@ -23,6 +24,10 @@ class DownloadsMenuProvider(
R.id.action_resume -> viewModel.resumeAll() R.id.action_resume -> viewModel.resumeAll()
R.id.action_cancel_all -> confirmCancelAll() R.id.action_cancel_all -> confirmCancelAll()
R.id.action_remove_completed -> confirmRemoveCompleted() R.id.action_remove_completed -> confirmRemoveCompleted()
R.id.action_settings -> {
context.startActivity(SettingsActivity.newDownloadsSettingsIntent(context))
}
else -> return false else -> return false
} }
return true return true

View File

@@ -32,7 +32,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.util.UUID import java.util.UUID
import com.google.android.material.R as materialR import com.google.android.material.R as materialR

View File

@@ -60,7 +60,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit

View File

@@ -9,7 +9,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import javax.inject.Inject import javax.inject.Inject
class ExploreRepository @Inject constructor( class ExploreRepository @Inject constructor(

View File

@@ -112,7 +112,7 @@ class FavouriteCategoriesActivity :
} }
val intent = FavouritesActivity.newIntent(this, item) val intent = FavouritesActivity.newIntent(this, item)
val options = scaleUpActivityOptionsOf(view) val options = scaleUpActivityOptionsOf(view)
startActivity(intent, options.toBundle()) startActivity(intent, options)
} }
override fun onItemLongClick(item: FavouriteCategory, view: View): Boolean { override fun onItemLongClick(item: FavouriteCategory, view: View): Boolean {

View File

@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetFavoriteCategoriesBinding import org.koitharu.kotatsu.databinding.SheetFavoriteCategoriesBinding
import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter
@@ -81,6 +82,6 @@ class FavouriteCategoriesSheet :
) )
}, },
) )
}.show(fm, TAG) }.showDistinct(fm, TAG)
} }
} }

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet.Companion.KEY_MANGA_LIST import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet.Companion.KEY_MANGA_LIST
import org.koitharu.kotatsu.favourites.ui.categories.select.model.CategoriesHeaderItem import org.koitharu.kotatsu.favourites.ui.categories.select.model.CategoriesHeaderItem
@@ -25,7 +26,7 @@ class MangaCategoriesViewModel @Inject constructor(
private val favouritesRepository: FavouritesRepository, private val favouritesRepository: FavouritesRepository,
) : BaseViewModel() { ) : BaseViewModel() {
private val manga = requireNotNull(savedStateHandle.get<List<ParcelableManga>>(KEY_MANGA_LIST)).map { it.manga } private val manga = savedStateHandle.require<List<ParcelableManga>>(KEY_MANGA_LIST).map { it.manga }
private val header = CategoriesHeaderItem() private val header = CategoriesHeaderItem()
val content: StateFlow<List<ListModel>> = combine( val content: StateFlow<List<ListModel>> = combine(

View File

@@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
@@ -21,8 +20,6 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
@@ -30,19 +27,16 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class FavouritesListViewModel @Inject constructor( class FavouritesListViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val repository: FavouritesRepository, private val repository: FavouritesRepository,
private val trackingRepository: TrackingRepository, private val listExtraProvider: ListExtraProvider,
private val historyRepository: HistoryRepository, settings: AppSettings,
private val settings: AppSettings,
private val tagHighlighter: MangaTagHighlighter,
downloadScheduler: DownloadWorker.Scheduler, downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler), ListExtraProvider { ) : MangaListViewModel(settings, downloadScheduler) {
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
@@ -76,7 +70,7 @@ class FavouritesListViewModel @Inject constructor(
), ),
) )
else -> list.toUi(mode, this, tagHighlighter) else -> list.toUi(mode, listExtraProvider)
} }
}.catch { }.catch {
emit(listOf(it.toErrorState(canRetry = false))) emit(listOf(it.toErrorState(canRetry = false)))
@@ -108,20 +102,4 @@ class FavouritesListViewModel @Inject constructor(
repository.setCategoryOrder(categoryId, order) repository.setCategoryOrder(categoryId, order)
} }
} }
override suspend fun getCounter(mangaId: Long): Int {
return if (settings.isTrackerEnabled) {
trackingRepository.getNewChaptersCount(mangaId)
} else {
0
}
}
override suspend fun getProgress(mangaId: Long): Float {
return if (settings.isReadingIndicatorsEnabled) {
historyRepository.getProgress(mangaId)
} else {
PROGRESS_NONE
}
}
} }

View File

@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.lifecycleScope import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterItem import org.koitharu.kotatsu.filter.ui.model.FilterItem
@@ -35,7 +36,6 @@ import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.text.Collator import java.text.Collator
import java.util.LinkedList import java.util.LinkedList
import java.util.Locale import java.util.Locale
@@ -49,7 +49,7 @@ class FilterCoordinator @Inject constructor(
dataRepository: MangaDataRepository, dataRepository: MangaDataRepository,
private val searchRepository: MangaSearchRepository, private val searchRepository: MangaSearchRepository,
lifecycle: ViewModelLifecycle, lifecycle: ViewModelLifecycle,
) : FilterOwner { ) : MangaFilter {
private val coroutineScope = lifecycle.lifecycleScope private val coroutineScope = lifecycle.lifecycleScope
private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE)) private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE))

View File

@@ -19,9 +19,8 @@ import com.google.android.material.R as materialR
class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsView.OnChipClickListener { class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsView.OnChipClickListener {
private val owner by lazy(LazyThreadSafetyMode.NONE) { private val filter: MangaFilter
FilterOwner.from(requireActivity()) get() = (requireActivity() as FilterOwner).filter
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding { override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding {
return FragmentFilterHeaderBinding.inflate(inflater, container, false) return FragmentFilterHeaderBinding.inflate(inflater, container, false)
@@ -30,7 +29,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
binding.chipsTags.onChipClickListener = this binding.chipsTags.onChipClickListener = this
owner.header.observe(viewLifecycleOwner, ::onDataChanged) filter.header.observe(viewLifecycleOwner, ::onDataChanged)
} }
override fun onWindowInsetsChanged(insets: Insets) = Unit override fun onWindowInsetsChanged(insets: Insets) = Unit
@@ -40,7 +39,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
if (tag == null) { if (tag == null) {
FilterSheetFragment.show(parentFragmentManager) FilterSheetFragment.show(parentFragmentManager)
} else { } else {
owner.onTagItemClick(FilterItem.Tag(tag, !chip.isChecked)) filter.onTagItemClick(FilterItem.Tag(tag, !chip.isChecked))
} }
} }

View File

@@ -1,32 +1,6 @@
package org.koitharu.kotatsu.filter.ui package org.koitharu.kotatsu.filter.ui
import androidx.fragment.app.Fragment interface FilterOwner {
import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.core.util.ext.values
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaTag
interface FilterOwner : OnFilterChangedListener { val filter: MangaFilter
val filterItems: StateFlow<List<ListModel>>
val header: StateFlow<FilterHeaderModel>
fun applyFilter(tags: Set<MangaTag>)
companion object {
fun from(activity: FragmentActivity): FilterOwner {
for (f in activity.supportFragmentManager.fragments) {
return find(f) ?: continue
}
error("Cannot find FilterOwner")
}
fun find(fragment: Fragment): FilterOwner? {
return fragment.viewModelStore.values.firstNotNullOfOrNull { it as? FilterOwner }
}
}
} }

View File

@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.databinding.SheetFilterBinding import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -20,20 +21,17 @@ class FilterSheetFragment :
AdaptiveSheetCallback, AdaptiveSheetCallback,
AsyncListDiffer.ListListener<ListModel> { AsyncListDiffer.ListListener<ListModel> {
private val owner by lazy(LazyThreadSafetyMode.NONE) {
FilterOwner.from(requireActivity())
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding { override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
return SheetFilterBinding.inflate(inflater, container, false) return SheetFilterBinding.inflate(inflater, container, false)
} }
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
val filter = (requireActivity() as FilterOwner).filter
addSheetCallback(this) addSheetCallback(this)
val adapter = FilterAdapter(owner, this) val adapter = FilterAdapter(filter, this)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
owner.filterItems.observe(viewLifecycleOwner, adapter::setItems) filter.filterItems.observe(viewLifecycleOwner, adapter::setItems)
if (dialog == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (dialog == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding.recyclerView.scrollIndicators = 0 binding.recyclerView.scrollIndicators = 0
@@ -54,6 +52,6 @@ class FilterSheetFragment :
private const val TAG = "FilterBottomSheet" private const val TAG = "FilterBottomSheet"
fun show(fm: FragmentManager) = FilterSheetFragment().show(fm, TAG) fun show(fm: FragmentManager) = FilterSheetFragment().showDistinct(fm, TAG)
} }
} }

View File

@@ -0,0 +1,15 @@
package org.koitharu.kotatsu.filter.ui
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaTag
interface MangaFilter : OnFilterChangedListener {
val filterItems: StateFlow<List<ListModel>>
val header: StateFlow<FilterHeaderModel>
fun applyFilter(tags: Set<MangaTag>)
}

View File

@@ -1,6 +1,10 @@
package org.koitharu.kotatsu.history.data package org.koitharu.kotatsu.history.data
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.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
@@ -20,6 +24,10 @@ abstract class HistoryDao {
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC") @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC")
abstract fun observeAll(): Flow<List<HistoryWithManga>> abstract fun observeAll(): Flow<List<HistoryWithManga>>
@Transaction
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit")
abstract fun observeAll(limit: Int): Flow<List<HistoryWithManga>>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history WHERE deleted_at = 0)") @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history WHERE deleted_at = 0)")
abstract suspend fun findAllManga(): List<MangaEntity> abstract suspend fun findAllManga(): List<MangaEntity>

View File

@@ -45,12 +45,25 @@ class HistoryRepository @Inject constructor(
return entity.manga.toManga(entity.tags.toMangaTags()) return entity.manga.toManga(entity.tags.toMangaTags())
} }
fun observeLast(): Flow<Manga?> {
return db.historyDao.observeAll(1).map {
val first = it.firstOrNull()
first?.manga?.toManga(first.tags.toMangaTags())
}
}
fun observeAll(): Flow<List<Manga>> { fun observeAll(): Flow<List<Manga>> {
return db.historyDao.observeAll().mapItems { return db.historyDao.observeAll().mapItems {
it.manga.toManga(it.tags.toMangaTags()) it.manga.toManga(it.tags.toMangaTags())
} }
} }
fun observeAll(limit: Int): Flow<List<Manga>> {
return db.historyDao.observeAll(limit).mapItems {
it.manga.toManga(it.tags.toMangaTags())
}
}
fun observeAllWithHistory(): Flow<List<MangaWithHistory>> { fun observeAllWithHistory(): Flow<List<MangaWithHistory>> {
return db.historyDao.observeAll().mapItems { return db.historyDao.observeAll().mapItems {
MangaWithHistory( MangaWithHistory(

View File

@@ -1,13 +1,15 @@
package org.koitharu.kotatsu.history.domain package org.koitharu.kotatsu.history.domain
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import javax.inject.Inject import javax.inject.Inject
class HistoryUpdateUseCase @Inject constructor( class HistoryUpdateUseCase @Inject constructor(
@@ -30,7 +32,9 @@ class HistoryUpdateUseCase @Inject constructor(
percent: Float percent: Float
) = processLifecycleScope.launch(Dispatchers.Default) { ) = processLifecycleScope.launch(Dispatchers.Default) {
runCatchingCancellable { runCatchingCancellable {
invoke(manga, readerState, percent) withContext(NonCancellable) {
invoke(manga, readerState, percent)
}
}.onFailure { }.onFailure {
it.printStackTraceDebug() it.printStackTraceDebug()
} }

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