Compare commits

...

123 Commits
v4.2 ... v4.4.3

Author SHA1 Message Date
Eric
737ca4a916 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (425 of 425 strings)

Co-authored-by: Eric <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-03-04 07:41:04 +02:00
Oğuz Ersen
b2958d03e4 Translated using Weblate (Turkish)
Currently translated at 100.0% (425 of 425 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-03-04 07:41:04 +02:00
J. Lavoie
af8550744f Translated using Weblate (French)
Currently translated at 100.0% (425 of 425 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-03-04 07:41:04 +02:00
gallegonovato
2f5fd71bb1 Translated using Weblate (Spanish)
Currently translated at 100.0% (425 of 425 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-03-04 07:41:04 +02:00
Dpper
271750ad93 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (424 of 424 strings)

Co-authored-by: Dpper <ruslan20020401@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-03-04 07:41:04 +02:00
Koitharu
0281c09dde Update version 2023-03-04 07:40:43 +02:00
Koitharu
f2ac3c331c Limit favourite category name length 2023-03-03 18:51:58 +02:00
Koitharu
4fc56f9786 Fix detailed list genres scrolling 2023-03-03 07:54:59 +02:00
Koitharu
a13c498d00 Fix ThemeChooserPreference memory leak 2023-03-02 18:42:48 +02:00
Koitharu
e15934bdc6 Option to inore SSL errors 2023-03-02 18:38:11 +02:00
Koitharu
4ec50f83d2 Update parsers 2023-02-25 18:44:31 +02:00
Koitharu
d0b9412559 Revert "Highlight suspicious genres"
This reverts commit 9adf209445.
2023-02-25 17:20:06 +02:00
Koitharu
9adf209445 Highlight suspicious genres 2023-02-25 17:15:07 +02:00
InfinityDouki56
5d2395b569 Translated using Weblate (Filipino)
Currently translated at 92.4% (392 of 424 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (8 of 8 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fil/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-02-23 19:48:42 +02:00
Koitharu
29114ae8a7 Sync logger 2023-02-22 20:20:57 +02:00
Koitharu
47f80085d1 Temporary disable sync for release builds 2023-02-22 07:56:30 +02:00
Koitharu
73c1d2a616 Show error details for pages 2023-02-21 18:59:52 +02:00
InfinityDouki56
35366ac660 Translated using Weblate (Filipino)
Currently translated at 56.1% (238 of 424 strings)

Translated using Weblate (Filipino)

Currently translated at 0.0% (0 of 8 strings)

Added translation using Weblate (Filipino)

Added translation using Weblate (Filipino)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fil/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-02-21 18:32:13 +02:00
J. Lavoie
dc2dd4e3c9 Translated using Weblate (Greek)
Currently translated at 21.6% (92 of 424 strings)

Translated using Weblate (French)

Currently translated at 100.0% (424 of 424 strings)

Translated using Weblate (French)

Currently translated at 98.8% (419 of 424 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/el/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-02-21 18:32:13 +02:00
Koitharu
66817ae545 Fix search bar hint font 2023-02-17 20:03:57 +02:00
Koitharu
b6e3cb929b Fix explore buttons color 2023-02-17 19:43:38 +02:00
Koitharu
6f29259395 Add "Grid mode" option to explore options menu 2023-02-17 18:58:37 +02:00
Koitharu
c520699f9f Fix crash with invalid domain 2023-02-17 07:39:59 +02:00
Koitharu
c09b0150ac Update parsers 2023-02-16 19:27:15 +02:00
Koitharu
d7c31f3b3b Translated using Weblate (Russian)
Currently translated at 100.0% (424 of 424 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-02-16 18:01:53 +02:00
gallegonovato
362629bb9a Translated using Weblate (Spanish)
Currently translated at 100.0% (424 of 424 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-02-16 18:01:53 +02:00
Raman
4ec4421f69 Translated using Weblate (Hindi)
Currently translated at 5.6% (24 of 423 strings)

Co-authored-by: Raman <translations.0l5zc@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2023-02-16 18:01:53 +02:00
Shippo
029815e0d7 Translated using Weblate (Arabic)
Currently translated at 21.9% (93 of 423 strings)

Co-authored-by: Shippo <Shipox@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2023-02-16 18:01:53 +02:00
Eric
019b41a9f9 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (424 of 424 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (423 of 423 strings)

Co-authored-by: Eric <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-02-16 18:01:53 +02:00
Koitharu
a56e977058 Show download started snackbar 2023-02-14 20:43:56 +02:00
Koitharu
f436a49e5f Add support for the predictive back gesture 2023-02-14 20:33:01 +02:00
Koitharu
652351f79a Improve downloads binding 2023-02-14 08:04:17 +02:00
Koitharu
b6bfef6b50 Option to allow updates to unstable app versions 2023-02-13 18:44:22 +02:00
Eric
c119db67e9 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (421 of 421 strings)

Co-authored-by: Eric <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-02-13 18:00:33 +02:00
Макар Разин
08e036f9fb Translated using Weblate (Serbian)
Currently translated at 8.7% (37 of 421 strings)

Translated using Weblate (Arabic)

Currently translated at 21.3% (90 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 (Turkish)

Currently translated at 98.3% (414 of 421 strings)

Translated using Weblate (French)

Currently translated at 98.3% (414 of 421 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (421 of 421 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/fr/
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/tr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-02-13 18:00:33 +02:00
Hosted Weblate
07519b82f3 Update translation files
Updated by "Remove blank strings" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/
Translation: Kotatsu/Strings
2023-02-12 10:12:39 +02:00
Grand-Priest0
2644756a01 Translated using Weblate (Hindi)
Currently translated at 100.0% (8 of 8 strings)

Added translation using Weblate (Hindi)

Added translation using Weblate (Hindi)

Co-authored-by: Grand-Priest0 <followtheanime@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/hi/
Translation: Kotatsu/plurals
2023-02-12 10:12:39 +02:00
Макар Разин
f6c715c5a7 Translated using Weblate (Arabic)
Currently translated at 20.1% (85 of 421 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Italian)

Currently translated at 98.0% (413 of 421 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Belarusian)

Currently translated at 99.7% (420 of 421 strings)

Translated using Weblate (Russian)

Currently translated at 98.8% (416 of 421 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-02-12 10:12:39 +02:00
Zakhar Timoshenko
81f3a40ba8 Translated using Weblate (Russian)
Currently translated at 99.7% (411 of 412 strings)

Co-authored-by: Zakhar Timoshenko <vp1984tanki@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-02-12 10:12:39 +02:00
Evgeniy Khramov
736be6249c Translated using Weblate (Russian)
Currently translated at 100.0% (411 of 411 strings)

Co-authored-by: Evgeniy Khramov <thejenjagamertjg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-02-12 10:12:39 +02:00
Allan Nordhøy
0add49f32c Translated using Weblate (Norwegian Bokmål)
Currently translated at 81.9% (337 of 411 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
Translation: Kotatsu/Strings
2023-02-12 10:12:39 +02:00
Shippo
1e2be37fd6 Translated using Weblate (Arabic)
Currently translated at 19.9% (81 of 406 strings)

Translated using Weblate (Arabic)

Currently translated at 37.5% (3 of 8 strings)

Co-authored-by: Shippo <Shipox@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-02-12 10:12:39 +02:00
Eric
529c6c7a08 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (415 of 415 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (412 of 412 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (411 of 411 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (406 of 406 strings)

Co-authored-by: Eric <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-02-12 10:12:39 +02:00
Koitharu
03251cbf9a Translated using Weblate (Russian)
Currently translated at 100.0% (406 of 406 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-02-12 10:12:39 +02:00
Oğuz Ersen
4ab9ace2f2 Translated using Weblate (Turkish)
Currently translated at 100.0% (406 of 406 strings)

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

Translated using Weblate (Spanish)

Currently translated at 100.0% (412 of 412 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (411 of 411 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (406 of 406 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-02-12 10:12:39 +02:00
kuzan
48b01d0706 Added translation using Weblate (English (Middle))
Co-authored-by: kuzan <3313631632@qq.com>
2023-02-12 10:12:39 +02:00
J. Lavoie
e2e0d7a53d Translated using Weblate (French)
Currently translated at 100.0% (412 of 412 strings)

Translated using Weblate (French)

Currently translated at 100.0% (411 of 411 strings)

Translated using Weblate (Italian)

Currently translated at 99.2% (408 of 411 strings)

Translated using Weblate (German)

Currently translated at 98.2% (404 of 411 strings)

Translated using Weblate (French)

Currently translated at 100.0% (406 of 406 strings)

Translated using Weblate (Italian)

Currently translated at 99.2% (403 of 406 strings)

Translated using Weblate (German)

Currently translated at 98.2% (399 of 406 strings)

Translated using Weblate (French)

Currently translated at 100.0% (405 of 405 strings)

Translated using Weblate (Italian)

Currently translated at 99.2% (402 of 405 strings)

Translated using Weblate (German)

Currently translated at 97.5% (395 of 405 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2023-02-12 10:12:39 +02:00
Koitharu
e3a67940d0 Add MemoryUsageView 2023-02-12 10:05:05 +02:00
Koitharu
5ce2bc92d6 Store and restore ThemeChooserPreference state 2023-02-12 09:44:53 +02:00
Koitharu
d05e777b2c Reduce safe parcel size #304 2023-02-11 07:49:42 +02:00
Zakhar Timoshenko
206673a417 Add Kanade theme 2023-02-11 00:50:07 +03:00
Zakhar Timoshenko
95e46249c5 Add Mamimi theme and made some theme tweaks 2023-02-11 00:25:07 +03:00
Koitharu
ea9ae2263c Reorganize settings 2023-02-10 21:47:57 +02:00
Zakhar Timoshenko
2acbff487e Update README 2023-02-10 21:47:57 +03:00
Koitharu
26b852365a Scrobblers config activity 2023-02-10 20:36:59 +02:00
Zakhar Timoshenko
c2e56f7ba6 Scrobbler icon adjusting on sheet 2023-02-09 20:33:40 +03:00
Zakhar Timoshenko
68e8876288 MAL final changes 2023-02-09 20:32:56 +03:00
Zakhar Timoshenko
5c44a4dbb3 Merge branch 'devel' into feature/mal 2023-02-09 17:34:32 +03:00
Koitharu
7a7ba802f6 Add more color schemes 2023-02-08 20:50:10 +02:00
Koitharu
c5ae9fb087 Use relative date format 2023-02-08 20:19:01 +02:00
Koitharu
e0f23d2e6d New headers processing approach 2023-02-08 19:57:17 +02:00
Koitharu
e9a972eec9 Merge branch 'master' into devel 2023-02-07 07:49:23 +02:00
Koitharu
155af8889b Update version 2023-02-07 07:40:33 +02:00
Koitharu
61b7117b97 Allow to use own UserAgent for each manga source 2023-02-07 07:29:38 +02:00
Zakhar Timoshenko
0f4de329e5 Update parsers 2023-02-07 07:27:40 +02:00
Zakhar Timoshenko
9b290bea40 Change user agent to Chrome 2023-02-07 07:27:29 +02:00
Koitharu
fd3c83cb13 Allow to use own UserAgent for each manga source 2023-02-06 19:45:55 +02:00
Zakhar Timoshenko
ec137d2513 Merge branch 'devel' into feature/mal 2023-02-05 18:31:22 +03:00
Zakhar Timoshenko
9da5bdaad4 Update parsers 2023-02-05 18:30:42 +03:00
Zakhar Timoshenko
eec1850712 Change user agent to Chrome 2023-02-05 18:30:28 +03:00
Zakhar Timoshenko
802ab4c6c1 Merge branch 'devel' into feature/mal 2023-02-05 09:45:55 +03:00
Koitharu
85d09dc48c Grid mode option for sources list 2023-02-05 08:19:07 +02:00
Koitharu
1daa02af52 Handle errors properly in scrobbler selector 2023-02-04 08:48:51 +02:00
Koitharu
1729505bfe Replace raster drawables with vectors 2023-02-03 20:11:21 +02:00
Koitharu
00617d5c64 Merge branch 'devel' into feature/mal 2023-02-03 20:01:22 +02:00
Koitharu
35b8003cf9 Color schemes 2023-02-03 19:39:14 +02:00
Zakhar Timoshenko
56ed8a787a MAL update №1 2023-02-03 02:23:15 +03:00
Koitharu
fd26de7619 Improve scrobbling ui 2023-02-01 20:21:20 +02:00
Koitharu
205a2e10a5 Fix scrobbling ui issues 2023-02-01 08:04:38 +02:00
Zakhar Timoshenko
8514cc3da7 Auth, search 2023-02-01 01:05:04 +03:00
Koitharu
8bc8df7625 Merge branch 'master' into devel 2023-01-30 20:27:20 +02:00
Koitharu
7ffa15d2d7 Update parsers 2023-01-30 20:11:14 +02:00
Zakhar Timoshenko
80be0e403d Update MAL codebase 2023-01-30 01:27:45 +03:00
Zakhar Timoshenko
ee2538ba7f Merge branch 'devel' into feature/mal
# Conflicts:
#	app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
#	app/src/main/java/org/koitharu/kotatsu/scrobbling/ScrobblingModule.kt
#	app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerService.kt
#	app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt
#	app/src/main/res/xml/pref_history.xml
2023-01-30 00:53:13 +03:00
Koitharu
6ca6ec28ac Complete AniList api integration #208 2023-01-28 20:35:22 +02:00
Koitharu
94203785f1 Merge branch 'devel' into feature/anilist 2023-01-27 19:38:00 +02:00
Koitharu
3f538d9b78 Move some OnBackPressed logics to OnBackPressedCallbacks 2023-01-27 19:26:31 +02:00
javlon
e6a0578884 Go back to Shelf screen onBackPressed() 2023-01-27 10:28:57 +02:00
Koitharu
e11e890818 Update parsers 2023-01-25 20:02:01 +02:00
Koitharu
3e7a48d27a Fix NPE during PagesCache initialization 2023-01-22 09:21:24 +02:00
Koitharu
eeba959ba5 Replace fadingEdges with scrollIndicators 2023-01-22 09:09:45 +02:00
Zakhar Timoshenko
e7fa1036be Fading chips on detailed list 2023-01-21 20:55:54 +03:00
Zakhar Timoshenko
542a7e1141 Merge remote-tracking branch 'origin/devel' into devel 2023-01-21 20:48:25 +03:00
Zakhar Timoshenko
5951f4438a Fix dialog background on Android 5 2023-01-21 20:47:56 +03:00
Koitharu
1fbae6bd7b Translated using Weblate (Russian)
Currently translated at 100.0% (405 of 405 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
Dan
b73924aea8 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (402 of 402 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (8 of 8 strings)

Co-authored-by: Dan <denqwerta@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/uk/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-01-21 19:14:27 +02:00
Shippo
005443f4ae Translated using Weblate (Arabic)
Currently translated at 14.9% (60 of 401 strings)

Co-authored-by: Shippo <Shipox@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
J. Lavoie
abb55d4424 Translated using Weblate (Italian)
Currently translated at 99.2% (398 of 401 strings)

Translated using Weblate (French)

Currently translated at 100.0% (401 of 401 strings)

Translated using Weblate (Italian)

Currently translated at 80.7% (324 of 401 strings)

Translated using Weblate (German)

Currently translated at 97.5% (391 of 401 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
ssantos
e0538da079 Translated using Weblate (Portuguese)
Currently translated at 99.2% (398 of 401 strings)

Translated using Weblate (Portuguese)

Currently translated at 66.5% (267 of 401 strings)

Co-authored-by: ssantos <ssantos@web.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
Eric
665bf5a034 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (405 of 405 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (402 of 402 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (401 of 401 strings)

Co-authored-by: Eric <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
Evgeniy Khramov
dc7e1282c6 Translated using Weblate (Russian)
Currently translated at 100.0% (401 of 401 strings)

Co-authored-by: Evgeniy Khramov <thejenjagamertjg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
Oğuz Ersen
3a877d4f4a Translated using Weblate (Turkish)
Currently translated at 100.0% (405 of 405 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (402 of 402 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (401 of 401 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
gallegonovato
8a23c9a327 Translated using Weblate (Spanish)
Currently translated at 100.0% (405 of 405 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (402 of 402 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (401 of 401 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
Koitharu
452c0edfc7 Fix Continue button behavior 2023-01-20 17:06:29 +02:00
Koitharu
2b9307aa17 Update dynamic colors 2023-01-20 15:48:54 +02:00
Koitharu
f91d5e1c29 Update gradle 2023-01-20 15:13:16 +02:00
Koitharu
2fbfd14252 Fix tracker cancellation errors 2023-01-20 14:28:59 +02:00
Koitharu
c09dd92cff Logger for debug logs 2023-01-20 11:32:29 +02:00
Koitharu
6b08074a70 Fix changelog formatting 2023-01-19 19:52:00 +02:00
Koitharu
9cb5971182 Option to change app language #282 2023-01-19 18:58:15 +02:00
Zakhar Timoshenko
6f37d95c24 Adjust alert dialogs to M3 guidelines 2023-01-19 07:37:00 +03:00
Zakhar Timoshenko
d290ba24b7 Use Markwon for pretty changelogs 2023-01-19 07:35:56 +03:00
Koitharu
f57d23026b Update room 2023-01-17 08:17:43 +02:00
Koitharu
1a70ccff55 Merge branch 'master' into devel 2023-01-17 07:41:09 +02:00
Koitharu
bd6a51e58d Fix crash on cold launch 2023-01-09 19:12:37 +02:00
Koitharu
a9c122b144 Option to mark chapter as current #56 2023-01-09 16:34:18 +02:00
Zakhar Timoshenko
2c71110fa5 Merge branch 'devel' into feature/mal
# Conflicts:
#	app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt
#	app/src/main/res/values/strings.xml
2023-01-04 18:51:59 +03:00
Koitharu
46786e32a3 Merge branch 'devel' into feature/anilist 2022-11-17 19:20:24 +02:00
Koitharu
eef449af49 Merge branch 'devel' into feature/mal 2022-11-17 19:12:17 +02:00
Koitharu
0c4c7489e9 Unify scrobblers implementation 2022-10-30 11:10:38 +02:00
Koitharu
743098d0b0 Basic AniList api implementation 2022-10-25 15:18:44 +03:00
Zakhar Timoshenko
2b2042807b Initial MyAnimeList implementation 2022-10-17 21:28:14 +03:00
290 changed files with 6637 additions and 1909 deletions

2
.gitignore vendored
View File

@@ -12,9 +12,11 @@
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/kotlinScripting.xml
/.idea/kotlinc.xml
/.idea/deploymentTargetDropDown.xml
/.idea/androidTestResultsUserPreferences.xml
/.idea/render.experimental.xml
/.idea/inspectionProfiles/
.DS_Store
/build
/captures

3
.idea/.gitignore generated vendored Normal file
View File

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

2
.idea/gradle.xml generated
View File

@@ -7,7 +7,7 @@
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="Embedded JDK" />
<option name="gradleJvm" value="jbr-17" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />

View File

@@ -1,17 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="BooleanLiteralArgument" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="Destructure" enabled="true" level="INFO" enabled_by_default="true" />
<inspection_tool class="FillClass" enabled="true" level="INFORMATION" enabled_by_default="true">
<option name="withoutDefaultValues" value="true" />
</inspection_tool>
<inspection_tool class="KeySetIterationMayUseEntrySet" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="KotlinFunctionArgumentsHelper" enabled="true" level="INFORMATION" enabled_by_default="true">
<option name="withoutDefaultValues" value="true" />
</inspection_tool>
<inspection_tool class="ReplaceCollectionCountWithSize" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="TrailingComma" enabled="true" level="INFORMATION" enabled_by_default="true" />
<inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

9
.idea/kotlinc.xml generated
View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Kotlin2JvmCompilerArguments">
<option name="jvmTarget" value="1.8" />
</component>
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.0" />
</component>
</project>

View File

@@ -16,7 +16,7 @@ Download APK directly from GitHub:
### Main Features
* Online manga catalogues
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
* Search manga by name and genres
* Reading history and bookmarks
* Favourites organized by user-defined categories
@@ -24,7 +24,7 @@ Download APK directly from GitHub:
* Tablet-optimized Material You UI
* Standard and Webtoon-optimized reader
* Notifications about new chapters with updates feed
* Shikimori integration (manga tracking)
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList
* Password/fingerprint protect access to the app
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices

View File

@@ -7,16 +7,16 @@ plugins {
}
android {
compileSdkVersion 33
buildToolsVersion '33.0.0'
namespace 'org.koitharu.kotatsu'
compileSdk = 33
buildToolsVersion = '33.0.1'
namespace = 'org.koitharu.kotatsu'
defaultConfig {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 33
versionCode 509
versionName '4.2'
versionCode 519
versionName '4.4.3'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -29,6 +29,9 @@ android {
// define this values in your local.properties file
buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', "\"${localProperty('shikimori.clientId')}\""
buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', "\"${localProperty('shikimori.clientSecret')}\""
buildConfigField 'String', 'ANILIST_CLIENT_ID', "\"${localProperty('anilist.clientId')}\""
buildConfigField 'String', 'ANILIST_CLIENT_SECRET', "\"${localProperty('anilist.clientSecret')}\""
buildConfigField 'String', 'MAL_CLIENT_ID', "\"${localProperty('mal.clientId')}\""
resValue "string", "acra_login", "${localProperty('acra.login')}"
resValue "string", "acra_password", "${localProperty('acra.password')}"
}
@@ -56,6 +59,7 @@ android {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += [
'-opt-in=kotlin.ExperimentalStdlibApi',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts',
@@ -83,13 +87,14 @@ afterEvaluate {
}
}
dependencies {
implementation('com.github.KotatsuApp:kotatsu-parsers:cf00732023') {
implementation('com.github.KotatsuApp:kotatsu-parsers:1093584202') {
exclude group: 'org.json', module: 'json'
}
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.0'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.10'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.activity:activity-ktx:1.6.1'
implementation 'androidx.fragment:fragment-ktx:1.5.5'
@@ -102,15 +107,15 @@ dependencies {
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'androidx.work:work-runtime-ktx:2.8.0'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.7.0'
implementation 'com.google.android.material:material:1.8.0'
//noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1'
implementation 'androidx.room:room-runtime:2.4.3'
implementation 'androidx.room:room-ktx:2.4.3'
kapt 'androidx.room:room-compiler:2.4.3'
implementation 'androidx.room:room-runtime:2.5.0'
implementation 'androidx.room:room-ktx:2.5.0'
kapt 'androidx.room:room-compiler:2.5.0'
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3'
@@ -119,8 +124,8 @@ dependencies {
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'com.google.dagger:hilt-android:2.44.2'
kapt 'com.google.dagger:hilt-compiler:2.44.2'
implementation 'com.google.dagger:hilt-android:2.45'
kapt 'com.google.dagger:hilt-compiler:2.45'
implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0'
@@ -128,6 +133,7 @@ dependencies {
implementation 'io.coil-kt:coil-svg:2.2.2'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'
implementation 'ch.acra:acra-http:5.9.7'
implementation 'ch.acra:acra-dialog:5.9.7'
@@ -145,9 +151,9 @@ dependencies {
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
androidTestImplementation 'androidx.room:room-testing:2.4.3'
androidTestImplementation 'androidx.room:room-testing:2.5.0'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.14.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.44.2'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.44.2'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.45'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.45'
}

View File

@@ -0,0 +1,56 @@
package org.koitharu.kotatsu.core.network
import android.util.Log
import okhttp3.Interceptor
import okhttp3.Response
import okio.Buffer
import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
class CurlLoggingInterceptor(
private val curlOptions: String? = null
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
var isCompressed = false
val curlCmd = StringBuilder()
curlCmd.append("curl")
if (curlOptions != null) {
curlCmd.append(' ').append(curlOptions)
}
curlCmd.append(" -X ").append(request.method)
for ((name, value) in request.headers) {
if (name.equals(ACCEPT_ENCODING, ignoreCase = true) && value.equals("gzip", ignoreCase = true)) {
isCompressed = true
}
curlCmd.append(" -H \"").append(name).append(": ").append(value.escape()).append('\"')
}
val body = request.body
if (body != null) {
val buffer = Buffer()
body.writeTo(buffer)
val charset = body.contentType()?.charset() ?: Charsets.UTF_8
curlCmd.append(" --data-raw '")
.append(buffer.readString(charset).replace("\n", "\\n"))
.append("'")
}
if (isCompressed) {
curlCmd.append(" --compressed")
}
curlCmd.append(" \"").append(request.url).append('"')
log("---cURL (" + request.url + ")")
log(curlCmd.toString())
return chain.proceed(request)
}
private fun String.escape() = replace("\"", "\\\"")
private fun log(msg: String) {
Log.d("CURL", msg)
}
}

View File

@@ -1,15 +1,20 @@
package org.koitharu.kotatsu.core.parser
import java.util.*
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet
/**
* This parser is just for parser development, it should not be used in releases
*/
class DummyParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.DUMMY) {
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("", null)
@@ -37,4 +42,4 @@ class DummyParser(override val context: MangaLoaderContext) : MangaParser(MangaS
override suspend fun getTags(): Set<MangaTag> {
TODO("Not yet implemented")
}
}
}

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<bool name="leak_canary_add_launcher_icon" tools:node="replace">false</bool>
</resources>
<bool name="is_sync_enabled">true</bool>
</resources>

View File

@@ -24,6 +24,7 @@
android:allowBackup="true"
android:backupAgent="org.koitharu.kotatsu.settings.backup.AppBackupAgent"
android:dataExtractionRules="@xml/backup_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_content"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher"
@@ -85,16 +86,7 @@
<activity
android:name="org.koitharu.kotatsu.settings.SettingsActivity"
android:exported="true"
android:label="@string/settings">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kotatsu" />
</intent-filter>
</activity>
android:label="@string/settings" />
<activity
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
@@ -140,6 +132,22 @@
<activity
android:name="org.koitharu.kotatsu.shelf.ui.config.ShelfSettingsActivity"
android:label="@string/settings" />
<activity
android:name="org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity"
android:exported="true"
android:label="@string/settings"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kotatsu" />
</intent-filter>
</activity>
<service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"

View File

@@ -50,6 +50,7 @@ class KotatsuApp : Application(), Configuration.Provider {
enableStrictMode()
}
AppCompatDelegate.setDefaultNightMode(settings.theme)
AppCompatDelegate.setApplicationLocales(settings.appLocales)
setupActivityLifecycleCallbacks()
processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers()

View File

@@ -4,11 +4,6 @@ import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Size
import androidx.room.withTransaction
import java.io.File
import java.io.InputStream
import java.util.zip.ZipFile
import javax.inject.Inject
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -17,7 +12,11 @@ import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.ReaderMode
@@ -27,6 +26,11 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import java.io.File
import java.io.InputStream
import java.util.zip.ZipFile
import javax.inject.Inject
import kotlin.math.roundToInt
private const val MIN_WEBTOON_RATIO = 2
@@ -121,7 +125,7 @@ class MangaDataRepository @Inject constructor(
val request = Request.Builder()
.url(url)
.get()
.header(CommonHeaders.REFERER, page.referer)
.tag(MangaSource::class.java, page.source)
.cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
.build()
okHttpClient.newCall(request).await().use {

View File

@@ -5,6 +5,7 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.annotation.CallSuper
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.viewbinding.ViewBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -39,6 +40,8 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
open fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder = builder
open fun onDialogCreated(dialog: AlertDialog) = Unit
protected fun bindingOrNull(): B? = viewBinding
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B

View File

@@ -12,6 +12,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.ActionBarContextView
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.core.view.ViewCompat
@@ -51,12 +52,9 @@ abstract class BaseActivity<B : ViewBinding> :
override fun onCreate(savedInstanceState: Bundle?) {
EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).inject(this)
val isAmoled = settings.isAmoledTheme
val isDynamic = settings.isDynamicTheme
when {
isAmoled && isDynamic -> setTheme(R.style.Theme_Kotatsu_Monet_Amoled)
isAmoled -> setTheme(R.style.Theme_Kotatsu_Amoled)
isDynamic -> setTheme(R.style.Theme_Kotatsu_Monet)
setTheme(settings.colorScheme.styleResId)
if (settings.isAmoledTheme) {
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
}
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -84,14 +82,14 @@ abstract class BaseActivity<B : ViewBinding> :
}
override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) {
@Suppress("DEPRECATION")
onBackPressed()
true
} else super.onOptionsItemSelected(item)
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { // TODO remove
// ActivityCompat.recreate(this)
TODO("Test error")
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
ActivityCompat.recreate(this)
return true
}
return super.onKeyDown(keyCode, event)
@@ -133,7 +131,8 @@ abstract class BaseActivity<B : ViewBinding> :
window.statusBarColor = getThemeColor(android.R.attr.statusBarColor)
}
@Suppress("OVERRIDE_DEPRECATION", "DEPRECATION")
@Suppress("DEPRECATION", "DeprecatedCallableAddReplaceWith")
@Deprecated("Should not be used")
override fun onBackPressed() {
if ( // https://issuetracker.google.com/issues/139738913
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&

View File

@@ -7,15 +7,16 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import androidx.activity.OnBackPressedDispatcher
import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding
import com.google.android.material.R as materialR
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.AppBottomSheetDialog
import org.koitharu.kotatsu.utils.ext.displayCompat
import com.google.android.material.R as materialR
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
@@ -27,6 +28,12 @@ abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
protected val behavior: BottomSheetBehavior<*>?
get() = (dialog as? BottomSheetDialog)?.behavior
val isExpanded: Boolean
get() = behavior?.state == BottomSheetBehavior.STATE_EXPANDED
val onBackPressedDispatcher: OnBackPressedDispatcher
get() = (requireDialog() as AppBottomSheetDialog).onBackPressedDispatcher
final override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.base.ui.util
import android.view.MenuItem
import android.view.MenuItem.OnActionExpandListener
import androidx.activity.OnBackPressedCallback
class CollapseActionViewCallback(
private val menuItem: MenuItem
) : OnBackPressedCallback(menuItem.isActionViewExpanded), OnActionExpandListener {
override fun handleOnBackPressed() {
menuItem.collapseActionView()
}
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
isEnabled = true
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
isEnabled = false
return true
}
}

View File

@@ -8,10 +8,12 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior
import androidx.core.view.ViewCompat
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
class ShrinkOnScrollBehavior : Behavior<ExtendedFloatingActionButton> {
open class ShrinkOnScrollBehavior : Behavior<ExtendedFloatingActionButton> {
@Suppress("unused") constructor() : super()
@Suppress("unused") constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
@Suppress("unused")
constructor() : super()
@Suppress("unused")
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
override fun onStartNestedScroll(
coordinatorLayout: CoordinatorLayout,
@@ -45,4 +47,4 @@ class ShrinkOnScrollBehavior : Behavior<ExtendedFloatingActionButton> {
}
}
}
}
}

View File

@@ -0,0 +1,55 @@
package org.koitharu.kotatsu.base.ui.util
import android.view.View
import androidx.annotation.Px
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.parsers.util.toIntUp
import kotlin.math.abs
class SpanSizeResolver(
private val recyclerView: RecyclerView,
@Px private val minItemWidth: Int,
) : View.OnLayoutChangeListener {
fun attach() {
recyclerView.addOnLayoutChangeListener(this)
}
fun detach() {
recyclerView.removeOnLayoutChangeListener(this)
}
override fun onLayoutChange(
v: View?,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int,
) {
invalidateInternal(abs(right - left))
}
fun invalidate() {
invalidateInternal(recyclerView.width)
}
private fun invalidateInternal(width: Int) {
if (width <= 0) {
return
}
val lm = recyclerView.layoutManager as? GridLayoutManager ?: return
val estimatedCount = (width / minItemWidth.toFloat()).toIntUp()
if (lm.spanCount != estimatedCount) {
lm.spanCount = estimatedCount
lm.spanSizeLookup?.run {
invalidateSpanGroupIndexCache()
invalidateSpanIndexCache()
}
}
}
}

View File

@@ -4,10 +4,12 @@ import android.animation.LayoutTransition
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import androidx.annotation.AttrRes
import androidx.annotation.MenuRes
import androidx.annotation.StringRes
import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout
@@ -15,16 +17,16 @@ import androidx.core.content.withStyledAttributes
import androidx.core.view.*
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import com.google.android.material.R as materialR
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.bottomsheet.BottomSheetBehavior
import java.util.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.LayoutSheetHeaderBinding
import org.koitharu.kotatsu.utils.ext.getAnimationDuration
import org.koitharu.kotatsu.utils.ext.getThemeDrawable
import org.koitharu.kotatsu.utils.ext.parents
import java.util.*
import com.google.android.material.R as materialR
private const val THROTTLE_DELAY = 200L
@@ -53,6 +55,9 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
val toolbar: MaterialToolbar
get() = binding.toolbar
val menu: Menu
get() = binding.toolbar.menu
var title: CharSequence?
get() = binding.toolbar.title
set(value) {
@@ -140,6 +145,10 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
binding.toolbar.invalidateMenu()
}
fun inflateMenu(@MenuRes resId: Int) {
binding.toolbar.inflateMenu(resId)
}
fun setNavigationOnClickListener(onClickListener: OnClickListener) {
binding.toolbar.setNavigationOnClickListener(onClickListener)
}
@@ -258,6 +267,7 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
}
lp
}
else -> Toolbar.LayoutParams(params)
}
}
@@ -282,7 +292,7 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
suppressLayoutCompat(false)
}
private inner class Callback : BottomSheetBehavior.BottomSheetCallback(), View.OnClickListener {
private inner class Callback : BottomSheetBehavior.BottomSheetCallback(), OnClickListener {
override fun onStateChanged(bottomSheet: View, newState: Int) {
onBottomSheetStateChanged(newState)

View File

@@ -1,119 +0,0 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.annotation.AttrRes
import androidx.annotation.IdRes
import androidx.core.view.children
import com.google.android.material.R as materialR
import com.google.android.material.button.MaterialButton
import com.google.android.material.shape.ShapeAppearanceModel
class CheckableButtonGroup @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = materialR.attr.materialButtonToggleGroupStyle,
) : LinearLayout(context, attrs, defStyleAttr, materialR.style.Widget_MaterialComponents_MaterialButtonToggleGroup),
View.OnClickListener {
private val originalCornerData = ArrayList<CornerData>()
var onCheckedChangeListener: OnCheckedChangeListener? = null
override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
if (child is MaterialButton) {
setupButton(child)
}
super.addView(child, index, params)
}
override fun onFinishInflate() {
super.onFinishInflate()
updateChildShapes()
}
override fun onClick(v: View) {
setCheckedId(v.id)
}
fun setCheckedId(@IdRes viewRes: Int) {
children.forEach {
(it as? MaterialButton)?.isChecked = it.id == viewRes
}
onCheckedChangeListener?.onCheckedChanged(this, viewRes)
}
private fun updateChildShapes() {
val childCount = childCount
val firstVisibleChildIndex = 0
val lastVisibleChildIndex = childCount - 1
for (i in 0 until childCount) {
val button: MaterialButton = getChildAt(i) as? MaterialButton ?: continue
if (button.visibility == GONE) {
continue
}
val builder = button.shapeAppearanceModel.toBuilder()
val newCornerData: CornerData? =
getNewCornerData(i, firstVisibleChildIndex, lastVisibleChildIndex)
updateBuilderWithCornerData(builder, newCornerData)
button.shapeAppearanceModel = builder.build()
}
}
private fun setupButton(button: MaterialButton) {
button.setOnClickListener(this)
button.isElegantTextHeight = false
// Saves original corner data
val shapeAppearanceModel: ShapeAppearanceModel = button.shapeAppearanceModel
originalCornerData.add(
CornerData(
shapeAppearanceModel.topLeftCornerSize,
shapeAppearanceModel.bottomLeftCornerSize,
shapeAppearanceModel.topRightCornerSize,
shapeAppearanceModel.bottomRightCornerSize,
),
)
}
private fun getNewCornerData(
index: Int,
firstVisibleChildIndex: Int,
lastVisibleChildIndex: Int,
): CornerData? {
val cornerData: CornerData = originalCornerData.get(index)
// If only one (visible) child exists, use its original corners
if (firstVisibleChildIndex == lastVisibleChildIndex) {
return cornerData
}
val isHorizontal = orientation == HORIZONTAL
if (index == firstVisibleChildIndex) {
return if (isHorizontal) cornerData.start(this) else cornerData.top()
}
return if (index == lastVisibleChildIndex) {
if (isHorizontal) cornerData.end(this) else cornerData.bottom()
} else null
}
private fun updateBuilderWithCornerData(
shapeAppearanceModelBuilder: ShapeAppearanceModel.Builder,
cornerData: CornerData?,
) {
if (cornerData == null) {
shapeAppearanceModelBuilder.setAllCornerSizes(0f)
return
}
shapeAppearanceModelBuilder
.setTopLeftCornerSize(cornerData.topLeft)
.setBottomLeftCornerSize(cornerData.bottomLeft)
.setTopRightCornerSize(cornerData.topRight)
.setBottomRightCornerSize(cornerData.bottomRight)
}
fun interface OnCheckedChangeListener {
fun onCheckedChanged(group: CheckableButtonGroup, checkedId: Int)
}
}

View File

@@ -1,47 +0,0 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.view.View
import androidx.core.view.ViewCompat
import com.google.android.material.shape.AbsoluteCornerSize
import com.google.android.material.shape.CornerSize
class CornerData(
var topLeft: CornerSize,
var bottomLeft: CornerSize,
var topRight: CornerSize,
var bottomRight: CornerSize,
) {
fun start(view: View): CornerData {
return if (isLayoutRtl(view)) right() else left()
}
fun end(view: View): CornerData {
return if (isLayoutRtl(view)) left() else right()
}
fun left(): CornerData {
return CornerData(topLeft, bottomLeft, noCorner, noCorner)
}
fun right(): CornerData {
return CornerData(noCorner, noCorner, topRight, bottomRight)
}
fun top(): CornerData {
return CornerData(topLeft, noCorner, topRight, noCorner)
}
fun bottom(): CornerData {
return CornerData(noCorner, bottomLeft, noCorner, bottomRight)
}
private companion object {
val noCorner: CornerSize = AbsoluteCornerSize(0f)
fun isLayoutRtl(view: View): Boolean {
return ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_RTL
}
}
}

View File

@@ -0,0 +1,94 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Outline
import android.graphics.Paint
import android.graphics.Path
import android.util.AttributeSet
import android.view.View
import android.view.ViewOutlineProvider
import androidx.core.content.withStyledAttributes
import androidx.core.graphics.withClip
import com.google.android.material.drawable.DrawableUtils
import org.koitharu.kotatsu.R
class ShapeView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
private val corners = FloatArray(8)
private val outlinePath = Path()
private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG)
init {
context.withStyledAttributes(attrs, R.styleable.ShapeView, defStyleAttr) {
val cornerSize = getDimension(R.styleable.ShapeView_cornerSize, 0f)
corners[0] = getDimension(R.styleable.ShapeView_cornerSizeTopLeft, cornerSize)
corners[1] = corners[0]
corners[2] = getDimension(R.styleable.ShapeView_cornerSizeTopRight, cornerSize)
corners[3] = corners[2]
corners[4] = getDimension(R.styleable.ShapeView_cornerSizeBottomRight, cornerSize)
corners[5] = corners[4]
corners[6] = getDimension(R.styleable.ShapeView_cornerSizeBottomLeft, cornerSize)
corners[7] = corners[6]
strokePaint.color = getColor(R.styleable.ShapeView_strokeColor, Color.TRANSPARENT)
strokePaint.strokeWidth = getDimension(R.styleable.ShapeView_strokeWidth, 0f)
strokePaint.style = Paint.Style.STROKE
}
outlineProvider = OutlineProvider()
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
if (w != oldw || h != oldh) {
rebuildPath()
}
}
override fun draw(canvas: Canvas) {
canvas.withClip(outlinePath) {
super.draw(canvas)
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (strokePaint.strokeWidth > 0f) {
canvas.drawPath(outlinePath, strokePaint)
}
}
private fun rebuildPath() {
outlinePath.reset()
val w = width
val h = height
if (w > 0 && h > 0) {
outlinePath.addRoundRect(0f, 0f, w.toFloat(), h.toFloat(), corners, Path.Direction.CW)
}
}
private inner class OutlineProvider : ViewOutlineProvider() {
@SuppressLint("RestrictedApi")
override fun getOutline(view: View?, outline: Outline) {
val corner = corners[0]
var isRoundRect = true
for (item in corners) {
if (item != corner) {
isRoundRect = false
break
}
}
if (isRoundRect) {
outline.setRoundRect(0, 0, width, height, corner)
} else {
DrawableUtils.setOutlineToPath(outline, outlinePath)
}
}
}
}

View File

@@ -11,7 +11,6 @@ import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer
fun bookmarkListAD(
coil: ImageLoader,
@@ -26,8 +25,7 @@ fun bookmarkListAD(
binding.root.setOnLongClickListener(listener)
bind {
binding.imageViewThumb.newImageRequest(item.imageUrl)?.run {
referer(item.manga.publicUrl)
binding.imageViewThumb.newImageRequest(item.imageUrl, item.manga.source)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)

View File

@@ -14,7 +14,10 @@ import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
import org.koitharu.kotatsu.databinding.ItemBookmarksGroupBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.ext.clearItemDecorations
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
fun bookmarksGroupAD(
coil: ImageLoader,
@@ -45,8 +48,7 @@ fun bookmarksGroupAD(
binding.recyclerView.addItemDecoration(spacingDecoration)
selectionController.attachToRecyclerView(item.manga, binding.recyclerView)
}
binding.imageViewCover.newImageRequest(item.manga.coverUrl)?.run {
referer(item.manga.publicUrl)
binding.imageViewCover.newImageRequest(item.manga.coverUrl, item.manga.source)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)

View File

@@ -8,20 +8,20 @@ import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.ViewGroup
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import com.google.android.material.R as materialR
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.network.UserAgentInterceptor
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import com.google.android.material.R as materialR
@SuppressLint("SetJavaScriptEnabled")
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityBrowserBinding.inflate(layoutInflater))
@@ -31,10 +31,12 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
}
with(binding.webView.settings) {
javaScriptEnabled = true
userAgentString = UserAgentInterceptor.userAgent
userAgentString = CommonHeadersInterceptor.userAgentChrome
}
binding.webView.webViewClient = BrowserClient(this)
binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar)
onBackPressedCallback = WebViewBackPressedCallback(binding.webView)
onBackPressedDispatcher.addCallback(onBackPressedCallback)
if (savedInstanceState != null) {
return
}
@@ -72,6 +74,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
finishAfterTransition()
true
}
R.id.action_browser -> {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(binding.webView.url)
@@ -81,15 +84,8 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
}
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onBackPressed() {
if (binding.webView.canGoBack()) {
binding.webView.goBack()
} else {
super.onBackPressed()
}
else -> super.onOptionsItemSelected(item)
}
override fun onPause() {
@@ -116,6 +112,10 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
supportActionBar?.subtitle = subtitle
}
override fun onHistoryChanged() {
onBackPressedCallback.onHistoryChanged()
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.appbar.updatePadding(
top = insets.top,

View File

@@ -1,8 +1,8 @@
package org.koitharu.kotatsu.browser
interface BrowserCallback {
interface BrowserCallback : OnHistoryChangedListener {
fun onLoadingStateChanged(isLoading: Boolean)
fun onTitleChanged(title: CharSequence, subtitle: CharSequence?)
}
}

View File

@@ -4,7 +4,7 @@ import android.graphics.Bitmap
import android.webkit.WebView
import android.webkit.WebViewClient
class BrowserClient(private val callback: BrowserCallback) : WebViewClient() {
open class BrowserClient(private val callback: BrowserCallback) : WebViewClient() {
override fun onPageFinished(webView: WebView, url: String) {
super.onPageFinished(webView, url)
@@ -20,4 +20,9 @@ class BrowserClient(private val callback: BrowserCallback) : WebViewClient() {
super.onPageCommitVisible(view, url)
callback.onTitleChanged(view.title.orEmpty(), url)
}
}
override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
super.doUpdateVisitedHistory(view, url, isReload)
callback.onHistoryChanged()
}
}

View File

@@ -0,0 +1,6 @@
package org.koitharu.kotatsu.browser
fun interface OnHistoryChangedListener {
fun onHistoryChanged()
}

View File

@@ -0,0 +1,21 @@
package org.koitharu.kotatsu.browser
import android.webkit.WebView
import androidx.activity.OnBackPressedCallback
class WebViewBackPressedCallback(
private val webView: WebView,
) : OnBackPressedCallback(false), OnHistoryChangedListener {
init {
onHistoryChanged()
}
override fun handleOnBackPressed() {
webView.goBack()
}
override fun onHistoryChanged() {
isEnabled = webView.canGoBack()
}
}

View File

@@ -1,8 +1,14 @@
package org.koitharu.kotatsu.browser.cloudflare
interface CloudFlareCallback {
import org.koitharu.kotatsu.browser.BrowserCallback
interface CloudFlareCallback : BrowserCallback {
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) = Unit
fun onPageLoaded()
fun onCheckPassed()
}
}

View File

@@ -2,8 +2,8 @@ package org.koitharu.kotatsu.browser.cloudflare
import android.graphics.Bitmap
import android.webkit.WebView
import android.webkit.WebViewClient
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.browser.BrowserClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
private const val CF_CLEARANCE = "cf_clearance"
@@ -12,22 +12,22 @@ class CloudFlareClient(
private val cookieJar: MutableCookieJar,
private val callback: CloudFlareCallback,
private val targetUrl: String,
) : WebViewClient() {
) : BrowserClient(callback) {
private val oldClearance = getClearance()
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
checkClearance()
}
override fun onPageCommitVisible(view: WebView?, url: String?) {
override fun onPageCommitVisible(view: WebView, url: String?) {
super.onPageCommitVisible(view, url)
callback.onPageLoaded()
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
override fun onPageFinished(webView: WebView, url: String) {
super.onPageFinished(webView, url)
callback.onPageLoaded()
}

View File

@@ -8,12 +8,16 @@ import android.view.View
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.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.network.UserAgentInterceptor
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.databinding.FragmentCloudflareBinding
import org.koitharu.kotatsu.utils.ext.stringArgument
@@ -29,6 +33,8 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
@Inject
lateinit var cookieJar: MutableCookieJar
private var onBackPressedCallback: WebViewBackPressedCallback? = null
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -42,7 +48,7 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
cacheMode = WebSettings.LOAD_DEFAULT
domStorageEnabled = true
databaseEnabled = true
userAgentString = UserAgentInterceptor.userAgent
userAgentString = arguments?.getString(ARG_UA) ?: CommonHeadersInterceptor.userAgentChrome
}
binding.webView.webViewClient = CloudFlareClient(cookieJar, this, url.orEmpty())
CookieManager.getInstance().setAcceptThirdPartyCookies(binding.webView, true)
@@ -56,6 +62,7 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
override fun onDestroyView() {
binding.webView.stopLoading()
binding.webView.destroy()
onBackPressedCallback = null
super.onDestroyView()
}
@@ -63,6 +70,13 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
return super.onBuildDialog(builder).setNegativeButton(android.R.string.cancel, null)
}
override fun onDialogCreated(dialog: AlertDialog) {
super.onDialogCreated(dialog)
onBackPressedCallback = WebViewBackPressedCallback(binding.webView).also {
dialog.onBackPressedDispatcher.addCallback(it)
}
}
override fun onResume() {
super.onResume()
binding.webView.onResume()
@@ -87,14 +101,22 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
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) = CloudFlareDialog().withArgs(1) {
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

@@ -85,6 +85,7 @@ interface AppModule {
@Singleton
fun provideOkHttpClient(
localStorageManager: LocalStorageManager,
commonHeadersInterceptor: CommonHeadersInterceptor,
cookieJar: CookieJar,
settings: AppSettings,
): OkHttpClient {
@@ -95,10 +96,16 @@ interface AppModule {
writeTimeout(20, TimeUnit.SECONDS)
cookieJar(cookieJar)
dns(DoHManager(cache, settings))
if (settings.isSSLBypassEnabled) {
bypassSSLErrors()
}
cache(cache)
addInterceptor(GZipInterceptor())
addInterceptor(UserAgentInterceptor())
addInterceptor(commonHeadersInterceptor)
addInterceptor(CloudFlareInterceptor())
if (BuildConfig.DEBUG) {
addInterceptor(CurlLoggingInterceptor())
}
}.build()
}

View File

@@ -2,15 +2,4 @@ package org.koitharu.kotatsu.core.cache
import androidx.collection.LruCache
class DeferredLruCache<T>(maxSize: Int) : LruCache<ContentCache.Key, SafeDeferred<T>>(maxSize) {
override fun entryRemoved(
evicted: Boolean,
key: ContentCache.Key,
oldValue: SafeDeferred<T>,
newValue: SafeDeferred<T>?,
) {
super.entryRemoved(evicted, key, oldValue, newValue)
oldValue.cancel()
}
}
class DeferredLruCache<T>(maxSize: Int) : LruCache<ContentCache.Key, SafeDeferred<T>>(maxSize)

View File

@@ -19,15 +19,28 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.db.migrations.*
import org.koitharu.kotatsu.core.db.migrations.Migration10To11
import org.koitharu.kotatsu.core.db.migrations.Migration11To12
import org.koitharu.kotatsu.core.db.migrations.Migration12To13
import org.koitharu.kotatsu.core.db.migrations.Migration13To14
import org.koitharu.kotatsu.core.db.migrations.Migration14To15
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
import org.koitharu.kotatsu.core.db.migrations.Migration5To6
import org.koitharu.kotatsu.core.db.migrations.Migration6To7
import org.koitharu.kotatsu.core.db.migrations.Migration7To8
import org.koitharu.kotatsu.core.db.migrations.Migration8To9
import org.koitharu.kotatsu.core.db.migrations.Migration9To10
import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.FavouritesDao
import org.koitharu.kotatsu.history.data.HistoryDao
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.scrobbling.data.ScrobblingDao
import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
import org.koitharu.kotatsu.suggestions.data.SuggestionDao
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.tracker.data.TrackEntity

View File

@@ -13,16 +13,6 @@ abstract class PreferencesDao {
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?>
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(pref: MangaPrefsEntity): Long
@Update
abstract suspend fun update(pref: MangaPrefsEntity): Int
@Transaction
open suspend fun upsert(pref: MangaPrefsEntity) {
if (update(pref) == 0) {
insert(pref)
}
}
@Upsert
abstract suspend fun upsert(pref: MangaPrefsEntity)
}

View File

@@ -14,7 +14,7 @@ abstract class TagsDao {
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit"""
LIMIT :limit""",
)
abstract suspend fun findPopularTags(limit: Int): List<TagEntity>
@@ -24,7 +24,7 @@ abstract class TagsDao {
WHERE tags.source = :source
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit"""
LIMIT :limit""",
)
abstract suspend fun findPopularTags(source: String, limit: Int): List<TagEntity>
@@ -34,7 +34,7 @@ abstract class TagsDao {
WHERE tags.source = :source AND title LIKE :query
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit"""
LIMIT :limit""",
)
abstract suspend fun findTags(source: String, query: String, limit: Int): List<TagEntity>
@@ -44,22 +44,10 @@ abstract class TagsDao {
WHERE title LIKE :query
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit"""
LIMIT :limit""",
)
abstract suspend fun findTags(query: String, limit: Int): List<TagEntity>
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(tag: TagEntity): Long
@Update(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun update(tag: TagEntity): Int
@Transaction
open suspend fun upsert(tags: Iterable<TagEntity>) {
tags.forEach { tag ->
if (update(tag) <= 0) {
insert(tag)
}
}
}
}
@Upsert
abstract suspend fun upsert(tags: Iterable<TagEntity>)
}

View File

@@ -1,7 +1,9 @@
package org.koitharu.kotatsu.core.exceptions
import okhttp3.Headers
import okio.IOException
class CloudFlareProtectedException(
val url: String
) : IOException("Protected by CloudFlare")
val url: String,
val headers: Headers,
) : IOException("Protected by CloudFlare")

View File

@@ -1,12 +1,17 @@
package org.koitharu.kotatsu.core.exceptions.resolve
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.StringRes
import androidx.collection.ArrayMap
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Headers
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
@@ -16,6 +21,7 @@ import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import org.koitharu.kotatsu.utils.TaggedActivityResult
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.isSuccess
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
@@ -43,7 +49,7 @@ class ExceptionResolver private constructor(
}
suspend fun resolve(e: Throwable): Boolean = when (e) {
is CloudFlareProtectedException -> resolveCF(e.url)
is CloudFlareProtectedException -> resolveCF(e.url, e.headers)
is AuthRequiredException -> resolveAuthException(e.source)
is NotFoundException -> {
openInBrowser(e.url)
@@ -53,8 +59,8 @@ class ExceptionResolver private constructor(
else -> false
}
private suspend fun resolveCF(url: String): Boolean {
val dialog = CloudFlareDialog.newInstance(url)
private suspend fun resolveCF(url: String, headers: Headers): Boolean {
val dialog = CloudFlareDialog.newInstance(url, headers)
val fm = getFragmentManager()
return suspendCancellableCoroutine { cont ->
fm.clearFragmentResult(CloudFlareDialog.TAG)
@@ -94,5 +100,21 @@ class ExceptionResolver private constructor(
}
fun canResolve(e: Throwable) = getResolveStringId(e) != 0
fun showDetails(context: Context, e: Throwable) {
val stackTrace = e.stackTraceToString()
val dialog = MaterialAlertDialogBuilder(context)
.setTitle(e.getDisplayMessage(context.resources))
.setMessage(stackTrace)
.setPositiveButton(androidx.preference.R.string.copy) { _, _ ->
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.setPrimaryClip(
ClipData.newPlainText(context.getString(R.string.error), stackTrace),
)
}
.setNegativeButton(R.string.close, null)
.create()
dialog.show()
}
}
}

View File

@@ -11,6 +11,7 @@ import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
@@ -31,6 +32,7 @@ private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5
@Singleton
class AppUpdateRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val settings: AppSettings,
private val okHttp: OkHttpClient,
) {
@@ -64,7 +66,7 @@ class AppUpdateRepository @Inject constructor(
val currentVersion = VersionId(BuildConfig.VERSION_NAME)
val available = getAvailableVersions().asArrayList()
available.sortBy { it.versionId }
if (currentVersion.isStable) {
if (currentVersion.isStable && !settings.isUnstableUpdatesAllowed) {
available.retainAll { it.versionId.isStable }
}
available.maxByOrNull { it.versionId }
@@ -80,6 +82,12 @@ class AppUpdateRepository @Inject constructor(
return BuildConfig.DEBUG || getCertificateSHA1Fingerprint() == CERT_SHA1
}
suspend fun getCurrentVersionChangelog(): String? {
val currentVersion = VersionId(BuildConfig.VERSION_NAME)
val available = getAvailableVersions()
return available.find { x -> x.versionId == currentVersion }?.description
}
@Suppress("DEPRECATION")
@SuppressLint("PackageManagerGetSignatures")
private fun getCertificateSHA1Fingerprint(): String? = runCatching {

View File

@@ -0,0 +1,134 @@
package org.koitharu.kotatsu.core.logs
import android.content.Context
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.subdir
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.ConcurrentLinkedQueue
private const val DIR = "logs"
private const val FLUSH_DELAY = 2_000L
private const val MAX_SIZE_BYTES = 1024 * 1024 // 1 MB
class FileLogger(
context: Context,
private val settings: AppSettings,
name: String,
) {
val file by lazy {
val dir = context.getExternalFilesDir(DIR) ?: context.filesDir.subdir(DIR)
File(dir, "$name.log")
}
val isEnabled: Boolean
get() = settings.isLoggingEnabled
private val dateFormat = SimpleDateFormat.getDateTimeInstance(
SimpleDateFormat.SHORT,
SimpleDateFormat.SHORT,
Locale.ROOT,
)
private val buffer = ConcurrentLinkedQueue<String>()
private val mutex = Mutex()
private var flushJob: Job? = null
fun log(message: String, e: Throwable? = null) {
if (!isEnabled) {
return
}
val text = buildString {
append(dateFormat.format(Date()))
append(": ")
if (e != null) {
append("E!")
}
append(message)
if (e != null) {
append(' ')
append(e.stackTraceToString())
appendLine()
}
}
buffer.add(text)
postFlush()
}
inline fun log(messageProducer: () -> String) {
if (isEnabled) {
log(messageProducer())
}
}
suspend fun flush() {
if (!isEnabled) {
return
}
flushJob?.cancelAndJoin()
flushImpl()
}
private fun postFlush() {
if (flushJob?.isActive == true) {
return
}
flushJob = processLifecycleScope.launch(Dispatchers.Default) {
delay(FLUSH_DELAY)
runCatchingCancellable {
flushImpl()
}.onFailure {
it.printStackTraceDebug()
}
}
}
private suspend fun flushImpl() {
mutex.withLock {
if (buffer.isEmpty()) {
return
}
runInterruptible(Dispatchers.IO) {
if (file.length() > MAX_SIZE_BYTES) {
rotate()
}
FileOutputStream(file, true).use {
while (true) {
val message = buffer.poll() ?: break
it.write(message.toByteArray())
it.write('\n'.code)
}
it.flush()
}
}
}
}
@WorkerThread
private fun rotate() {
val length = file.length()
val bakFile = File(file.parentFile, file.name + ".bak")
file.renameTo(bakFile)
bakFile.inputStream().use { input ->
input.skip(length - MAX_SIZE_BYTES / 2)
file.outputStream().use { output ->
input.copyTo(output)
output.flush()
}
}
bakFile.delete()
}
}

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.logs
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class TrackerLogger
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SyncLogger

View File

@@ -0,0 +1,40 @@
package org.koitharu.kotatsu.core.logs
import android.content.Context
import androidx.collection.arraySetOf
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ElementsIntoSet
import org.koitharu.kotatsu.core.prefs.AppSettings
@Module
@InstallIn(SingletonComponent::class)
object LoggersModule {
@Provides
@TrackerLogger
fun provideTrackerLogger(
@ApplicationContext context: Context,
settings: AppSettings,
) = FileLogger(context, settings, "tracker")
@Provides
@SyncLogger
fun provideSyncLogger(
@ApplicationContext context: Context,
settings: AppSettings,
) = FileLogger(context, settings, "sync")
@Provides
@ElementsIntoSet
fun provideAllLoggers(
@TrackerLogger trackerLogger: FileLogger,
@SyncLogger syncLogger: FileLogger,
): Set<@JvmSuppressWildcards FileLogger> = arraySetOf(
trackerLogger,
syncLogger,
)
}

View File

@@ -5,8 +5,8 @@ import android.os.Parcelable
import org.koitharu.kotatsu.parsers.model.Manga
// Limits to avoid TransactionTooLargeException
private const val MAX_SAFE_SIZE = 1024 * 512 // Assume that 512 kb is safe parcel size
private const val MAX_SAFE_CHAPTERS_COUNT = 32 // this is 100% safe
private const val MAX_SAFE_SIZE = 1024 * 100 // Assume that 100 kb is safe parcel size
private const val MAX_SAFE_CHAPTERS_COUNT = 24 // this is 100% safe
class ParcelableManga(
val manga: Manga,

View File

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

View File

@@ -7,9 +7,11 @@ object CommonHeaders {
const val REFERER = "Referer"
const val USER_AGENT = "User-Agent"
const val ACCEPT = "Accept"
const val CONTENT_TYPE = "Content-Type"
const val CONTENT_DISPOSITION = "Content-Disposition"
const val COOKIE = "Cookie"
const val CONTENT_ENCODING = "Content-Encoding"
const val ACCEPT_ENCODING = "Accept-Encoding"
const val AUTHORIZATION = "Authorization"
val CACHE_CONTROL_DISABLED: CacheControl

View File

@@ -0,0 +1,85 @@
package org.koitharu.kotatsu.core.network
import android.os.Build
import android.util.Log
import dagger.Lazy
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mergeWith
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CommonHeadersInterceptor @Inject constructor(
private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val source = request.tag(MangaSource::class.java)
val repository = if (source != null) {
mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository
} else {
if (BuildConfig.DEBUG) {
Log.w("Http", "Request without source tag: ${request.url}")
}
null
}
val headersBuilder = request.headers.newBuilder()
repository?.headers?.let {
headersBuilder.mergeWith(it, replaceExisting = false)
}
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
headersBuilder[CommonHeaders.USER_AGENT] = userAgentFallback
}
if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) {
headersBuilder.trySet(CommonHeaders.REFERER, "https://${repository.domain}/")
}
val newRequest = request.newBuilder().headers(headersBuilder.build()).build()
return repository?.intercept(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest)
}
private fun Headers.Builder.trySet(name: String, value: String) = try {
set(name, value)
} catch (e: IllegalArgumentException) {
e.printStackTraceDebug()
}
private class ProxyChain(
private val delegate: Interceptor.Chain,
private val request: Request,
) : Interceptor.Chain by delegate {
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

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.core.network
import android.annotation.SuppressLint
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager
@SuppressLint("CustomX509TrustManager")
fun OkHttpClient.Builder.bypassSSLErrors() = also { builder ->
runCatching {
val trustAllCerts = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) = Unit
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) = Unit
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
val sslContext = SSLContext.getInstance("SSL")
sslContext.init(null, arrayOf(trustAllCerts), SecureRandom())
val sslSocketFactory: SSLSocketFactory = sslContext.socketFactory
builder.sslSocketFactory(sslSocketFactory, trustAllCerts)
builder.hostnameVerifier { _, _ -> true }
}.onFailure {
it.printStackTraceDebug()
}
}

View File

@@ -1,43 +0,0 @@
package org.koitharu.kotatsu.core.network
import android.os.Build
import java.util.*
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
class UserAgentInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
return chain.proceed(
if (request.header(CommonHeaders.USER_AGENT) == null) {
request.newBuilder()
.addHeader(CommonHeaders.USER_AGENT, userAgent)
.build()
} else request
)
}
companion object {
val userAgent
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

@@ -118,6 +118,7 @@ class ShortcutsUpdater @Inject constructor(
ImageRequest.Builder(context)
.data(manga.coverUrl)
.size(iconSize.width, iconSize.height)
.tag(manga.source)
.precision(Precision.EXACT)
.scale(Scale.FILL)
.build(),

View File

@@ -1,8 +1,14 @@
package org.koitharu.kotatsu.core.parser
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.cache.SafeDeferred
import org.koitharu.kotatsu.core.prefs.SourceSettings
@@ -16,13 +22,14 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
class RemoteMangaRepository(
private val parser: MangaParser,
private val cache: ContentCache,
) : MangaRepository {
) : MangaRepository, Interceptor {
override val source: MangaSource
get() = parser.source
@@ -36,6 +43,20 @@ class RemoteMangaRepository(
getConfig().defaultSortOrder = value
}
val domain: String
get() = parser.domain
val headers: Headers?
get() = parser.headers
override fun intercept(chain: Interceptor.Chain): Response {
return if (parser is Interceptor) {
parser.intercept(chain)
} else {
chain.proceed(chain.request())
}
}
override suspend fun getList(offset: Int, query: String): List<Manga> {
return parser.getList(offset, query)
}
@@ -76,9 +97,15 @@ class RemoteMangaRepository(
private fun getConfig() = parser.config as SourceSettings
private fun <T> asyncSafe(block: suspend CoroutineScope.() -> T) = SafeDeferred(
processLifecycleScope.async(Dispatchers.Default) {
runCatchingCancellable { block() }
},
)
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
dispatcher = Dispatchers.Default
}
return SafeDeferred(
processLifecycleScope.async(dispatcher) {
runCatchingCancellable { block() }
},
)
}
}

View File

@@ -20,7 +20,6 @@ import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.internal.closeQuietly
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.local.data.CacheDir
@@ -53,7 +52,7 @@ class FaviconFetcher(
options.size.height.pxOrElse { FALLBACK_SIZE },
)
val icon = checkNotNull(favicons.find(sizePx)) { "No favicons found" }
val response = loadIcon(icon.url, favicons.referer)
val response = loadIcon(icon.url, mangaSource)
val responseBody = response.requireBody()
val source = writeToDiskCache(responseBody)?.toImageSource() ?: responseBody.toImageSource()
return SourceResult(
@@ -63,11 +62,11 @@ class FaviconFetcher(
)
}
private suspend fun loadIcon(url: String, referer: String): Response {
private suspend fun loadIcon(url: String, source: MangaSource): Response {
val request = Request.Builder()
.url(url)
.get()
.header(CommonHeaders.REFERER, referer)
.tag(MangaSource::class.java, source)
@Suppress("UNCHECKED_CAST")
options.tags.asMap().forEach { request.tag(it.key as Class<Any>, it.value) }
val response = okHttpClient.newCall(request.build()).await()

View File

@@ -7,8 +7,8 @@ import android.provider.Settings
import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.arraySetOf
import androidx.core.content.edit
import androidx.core.os.LocaleListCompat
import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.ZoomMode
@@ -17,13 +17,12 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.shelf.domain.ShelfSection
import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.filterToSet
import org.koitharu.kotatsu.utils.ext.getEnumValue
import org.koitharu.kotatsu.utils.ext.observe
import org.koitharu.kotatsu.utils.ext.putEnumValue
import org.koitharu.kotatsu.utils.ext.toUriOrNull
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Collections
import java.util.EnumSet
import java.util.Locale
@@ -69,8 +68,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val theme: Int
get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
val isDynamicTheme: Boolean
get() = DynamicColors.isDynamicColorAvailable() && prefs.getBoolean(KEY_DYNAMIC_THEME, false)
val colorScheme: ColorScheme
get() = prefs.getEnumValue(KEY_COLOR_THEME, ColorScheme.default)
val isAmoledTheme: Boolean
get() = prefs.getBoolean(KEY_THEME_AMOLED, false)
@@ -79,6 +78,17 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getInt(KEY_GRID_SIZE, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
var appLocales: LocaleListCompat
get() {
val raw = prefs.getString(KEY_APP_LOCALE, null)
return LocaleListCompat.forLanguageTags(raw)
}
set(value) {
prefs.edit {
putString(KEY_APP_LOCALE, value.toLanguageTags())
}
}
val readerPageSwitch: Set<String>
get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS)
@@ -147,6 +157,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getString(KEY_APP_PASSWORD, null)
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
val isLoggingEnabled: Boolean
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
var isBiometricProtectionEnabled: Boolean
get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true)
set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
@@ -157,6 +170,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isDynamicShortcutsEnabled: Boolean
get() = prefs.getBoolean(KEY_SHORTCUTS, true)
val isUnstableUpdatesAllowed: Boolean
get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false)
fun isContentPrefetchEnabled(): Boolean {
val policy = NetworkPolicy.from(prefs.getString(KEY_PREFETCH_CONTENT, null), NetworkPolicy.NEVER)
return policy.isNetworkAllowed(connectivityManager)
@@ -171,7 +187,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
}
var hiddenSources: Set<String>
get() = prefs.getStringSet(KEY_SOURCES_HIDDEN, null) ?: emptySet()
get() = prefs.getStringSet(KEY_SOURCES_HIDDEN, null)?.filterToSet { name ->
remoteSources.any { it.name == name }
}.orEmpty()
set(value) = prefs.edit { putStringSet(KEY_SOURCES_HIDDEN, value) }
val isSourcesSelected: Boolean
@@ -191,6 +209,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
sourcesOrder = (sourcesOrder + sources.map { it.name }).distinct()
}
var isSourcesGridMode: Boolean
get() = prefs.getBoolean(KEY_SOURCES_GRID, false)
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
val isPagesNumbersEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
@@ -233,6 +255,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val dnsOverHttps: DoHProvider
get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE)
val isSSLBypassEnabled: Boolean
get() = prefs.getBoolean(KEY_SSL_BYPASS, false)
var localListOrder: SortOrder
get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST)
set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) }
@@ -245,12 +270,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return policy.isNetworkAllowed(connectivityManager)
}
fun getDateFormat(format: String = prefs.getString(KEY_DATE_FORMAT, "").orEmpty()): DateFormat =
when (format) {
"" -> DateFormat.getDateInstance(DateFormat.SHORT)
else -> SimpleDateFormat(format, Locale.getDefault())
}
fun getSuggestionsTagsBlacklistRegex(): Regex? {
val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',')
if (string.isNullOrEmpty()) {
@@ -297,9 +316,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LIST_MODE = "list_mode_2"
const val KEY_THEME = "theme"
const val KEY_DYNAMIC_THEME = "dynamic_theme"
const val KEY_COLOR_THEME = "color_theme"
const val KEY_THEME_AMOLED = "amoled_theme"
const val KEY_DATE_FORMAT = "date_format"
const val KEY_SOURCES_ORDER = "sources_order_2"
const val KEY_SOURCES_HIDDEN = "sources_hidden"
const val KEY_TRAFFIC_WARNING = "traffic_warning"
@@ -343,6 +361,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
const val KEY_SHIKIMORI = "shikimori"
const val KEY_ANILIST = "anilist"
const val KEY_MAL = "mal"
const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism"
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
@@ -358,6 +378,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
const val KEY_SHELF_SECTIONS = "shelf_sections_2"
const val KEY_PREFETCH_CONTENT = "prefetch_content"
const val KEY_APP_LOCALE = "app_locale"
const val KEY_LOGGING_ENABLED = "logging"
const val KEY_LOGS_SHARE = "logs_share"
const val KEY_SOURCES_GRID = "sources_grid"
const val KEY_UPDATES_UNSTABLE = "updates_unstable"
const val KEY_SSL_BYPASS = "ssl_bypass"
// About
const val KEY_APP_UPDATE = "app_update"

View File

@@ -1,8 +1,13 @@
package org.koitharu.kotatsu.core.prefs
import androidx.lifecycle.liveData
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transform
import kotlin.coroutines.CoroutineContext
fun <T> AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow {
var lastValue: T = valueProducer()
@@ -33,3 +38,13 @@ fun <T> AppSettings.observeAsLiveData(
}
}
}
fun <T> AppSettings.observeAsStateFlow(
key: String,
scope: CoroutineScope,
valueProducer: AppSettings.() -> T,
): StateFlow<T> = observe().transform {
if (it == key) {
emit(valueProducer())
}
}.stateIn(scope, SharingStarted.Eagerly, valueProducer())

View File

@@ -0,0 +1,45 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.StringRes
import androidx.annotation.StyleRes
import com.google.android.material.color.DynamicColors
import org.koitharu.kotatsu.R
enum class ColorScheme(
@StyleRes val styleResId: Int,
@StringRes val titleResId: Int,
) {
DEFAULT(R.style.Theme_Kotatsu, R.string.system_default),
MONET(R.style.Theme_Kotatsu_Monet, R.string.theme_name_dynamic),
MIKU(R.style.Theme_Kotatsu_Miku, R.string.theme_name_miku),
RENA(R.style.Theme_Kotatsu_Asuka, R.string.theme_name_asuka),
FROG(R.style.Theme_Kotatsu_Mion, R.string.theme_name_mion),
BLUEBERRY(R.style.Theme_Kotatsu_Rikka, R.string.theme_name_rikka),
NAME2(R.style.Theme_Kotatsu_Sakura, R.string.theme_name_sakura),
MAMIMI(R.style.Theme_Kotatsu_Mamimi, R.string.theme_name_mamimi),
KANADE(R.style.Theme_Kotatsu_Kanade, R.string.theme_name_kanade)
;
companion object {
val default: ColorScheme
get() = if (DynamicColors.isDynamicColorAvailable()) {
MONET
} else {
DEFAULT
}
fun getAvailableList(): List<ColorScheme> {
val list = enumValues<ColorScheme>().toMutableList()
if (!DynamicColors.isDynamicColorAvailable()) {
list.remove(MONET)
}
return list
}
fun safeValueOf(name: String): ColorScheme? {
return enumValues<ColorScheme>().find { it.name == name }
}
}
}

View File

@@ -23,7 +23,9 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
@Suppress("UNCHECKED_CAST")
override fun <T> get(key: ConfigKey<T>): T {
return when (key) {
is ConfigKey.UserAgent -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
} as T
}
}
}

View File

@@ -5,7 +5,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.format
import java.util.*
import java.util.Date
sealed class DateTimeAgo : ListModel {
@@ -17,6 +17,8 @@ sealed class DateTimeAgo : ListModel {
}
override fun toString() = "just_now"
override fun equals(other: Any?): Boolean = other === JustNow
}
class MinutesAgo(val minutes: Int) : DateTimeAgo() {
@@ -60,6 +62,8 @@ sealed class DateTimeAgo : ListModel {
}
override fun toString() = "today"
override fun equals(other: Any?): Boolean = other === Today
}
object Yesterday : DateTimeAgo() {
@@ -68,6 +72,8 @@ sealed class DateTimeAgo : ListModel {
}
override fun toString() = "yesterday"
override fun equals(other: Any?): Boolean = other === Yesterday
}
class DaysAgo(val days: Int) : DateTimeAgo() {
@@ -119,5 +125,7 @@ sealed class DateTimeAgo : ListModel {
}
override fun toString() = "long_ago"
override fun equals(other: Any?): Boolean = other === LongAgo
}
}
}

View File

@@ -4,7 +4,6 @@ import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import kotlinx.coroutines.coroutineScope
import org.koitharu.kotatsu.base.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
@@ -47,7 +46,7 @@ class MangaPrefetchService : CoroutineIntentService() {
override fun onError(startId: Int, error: Throwable) = Unit
private suspend fun prefetchDetails(manga: Manga) = coroutineScope {
private suspend fun prefetchDetails(manga: Manga) {
val source = mangaRepositoryFactory.create(manga.source)
runCatchingCancellable { source.getDetails(manga) }
}

View File

@@ -1,15 +1,16 @@
package org.koitharu.kotatsu.details.ui
import android.os.Bundle
import android.view.*
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.view.ActionMode
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar
import kotlin.math.roundToInt
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.ListSelectionController
@@ -24,8 +25,8 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback
import org.koitharu.kotatsu.utils.ext.parents
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import kotlin.math.roundToInt
class ChaptersFragment :
BaseFragment<FragmentChaptersBinding>(),
@@ -95,13 +96,14 @@ class ChaptersFragment :
return when (item.itemId) {
R.id.action_save -> {
DownloadService.start(
context ?: return false,
binding.recyclerViewChapters,
viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
selectionController?.snapshot(),
)
mode.finish()
true
}
R.id.action_delete -> {
val ids = selectionController?.peekCheckedIds()
val manga = viewModel.manga.value
@@ -120,6 +122,7 @@ class ChaptersFragment :
mode.finish()
true
}
R.id.action_select_range -> {
val items = chaptersAdapter?.items ?: return false
val ids = HashSet(controller.peekCheckedIds())
@@ -139,11 +142,20 @@ class ChaptersFragment :
controller.addAll(ids)
true
}
R.id.action_select_all -> {
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
selectionController?.addAll(ids)
controller.addAll(ids)
true
}
R.id.action_mark_current -> {
val id = controller.peekCheckedIds().singleOrNull() ?: return false
viewModel.markChapterAsCurrent(id)
mode.finish()
true
}
else -> false
}
}
@@ -164,6 +176,7 @@ class ChaptersFragment :
x.chapter.source == MangaSource.LOCAL
}
menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size
menu.findItem(R.id.action_mark_current).isVisible = items.size == 1
mode.title = items.size.toString()
var hasGap = false
for (i in 0 until items.size - 1) {

View File

@@ -277,7 +277,7 @@ class DetailsActivity :
)
}
setNeutralButton(R.string.download) { _, _ ->
DownloadService.start(this@DetailsActivity, remoteManga, setOf(chapterId))
DownloadService.start(binding.appbar, remoteManga, setOf(chapterId))
}
setCancelable(true)
}.show()

View File

@@ -40,7 +40,7 @@ import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.utils.FileSize
@@ -50,7 +50,6 @@ import org.koitharu.kotatsu.utils.ext.drawableTop
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.ifNullOrEmpty
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.resolveDp
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.utils.ext.textAndVisible
@@ -254,7 +253,11 @@ class DetailsFragment :
R.id.imageView_cover -> {
startActivity(
ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }),
ImageActivity.newIntent(
v.context,
manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl },
manga.source,
),
scaleUpActivityOptionsOf(v).toBundle(),
)
}
@@ -337,8 +340,8 @@ class DetailsFragment :
.target(binding.imageViewCover)
.size(CoverSizeResolver(binding.imageViewCover))
.data(imageUrl)
.tag(manga.source)
.crossfade(context)
.referer(manga.publicUrl)
.lifecycle(viewLifecycleOwner)
.placeholderMemoryCacheKey(manga.coverUrl)
val previousDrawable = lastResult?.drawable

View File

@@ -21,7 +21,7 @@ import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesB
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorBottomSheet
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorBottomSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.utils.ShareHelper
@@ -42,7 +42,7 @@ class DetailsMenuProvider(
menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL
menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
menu.findItem(R.id.action_shiki_track).isVisible = viewModel.isScrobblingAvailable
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
menu.findItem(R.id.action_favourite).setIcon(
if (viewModel.favouriteCategories.value == true) R.drawable.ic_heart else R.drawable.ic_heart_outline,
)
@@ -60,11 +60,13 @@ class DetailsMenuProvider(
}
}
}
R.id.action_favourite -> {
viewModel.manga.value?.let {
FavouriteCategoriesBottomSheet.show(activity.supportFragmentManager, it)
}
}
R.id.action_delete -> {
val title = viewModel.manga.value?.title.orEmpty()
MaterialAlertDialogBuilder(activity)
@@ -76,6 +78,7 @@ class DetailsMenuProvider(
.setNegativeButton(android.R.string.cancel, null)
.show()
}
R.id.action_save -> {
viewModel.manga.value?.let {
val chaptersCount = it.chapters?.size ?: 0
@@ -83,25 +86,29 @@ class DetailsMenuProvider(
if (chaptersCount > 5 || branches.size > 1) {
showSaveConfirmation(it, chaptersCount, branches)
} else {
DownloadService.start(activity, it)
DownloadService.start(snackbarHost, it)
}
}
}
R.id.action_browser -> {
viewModel.manga.value?.let {
activity.startActivity(BrowserActivity.newIntent(activity, it.publicUrl, it.title))
}
}
R.id.action_related -> {
viewModel.manga.value?.let {
activity.startActivity(MultiSearchActivity.newIntent(activity, it.title))
}
}
R.id.action_shiki_track -> {
R.id.action_scrobbling -> {
viewModel.manga.value?.let {
ScrobblingSelectorBottomSheet.show(activity.supportFragmentManager, it)
ScrobblingSelectorBottomSheet.show(activity.supportFragmentManager, it, null)
}
}
R.id.action_shortcut -> {
viewModel.manga.value?.let {
activity.lifecycleScope.launch {
@@ -112,6 +119,7 @@ class DetailsMenuProvider(
}
}
}
else -> return false
}
return true
@@ -132,7 +140,7 @@ class DetailsMenuProvider(
val chaptersIds = manga.chapters?.mapNotNullToSet { c ->
if (c.branch in selectedBranches) c.id else null
}
DownloadService.start(activity, manga, chaptersIds)
DownloadService.start(snackbarHost, manga, chaptersIds)
}
} else {
dialogBuilder.setMessage(
@@ -141,7 +149,7 @@ class DetailsMenuProvider(
activity.resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount),
),
).setPositiveButton(R.string.save) { _, _ ->
DownloadService.start(activity, manga)
DownloadService.start(snackbarHost, manga)
}
}
dialogBuilder.show()

View File

@@ -45,9 +45,9 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
@@ -72,7 +72,6 @@ class DetailsViewModel @AssistedInject constructor(
private val delegate = MangaDetailsDelegate(
intent = intent,
settings = settings,
mangaDataRepository = mangaDataRepository,
historyRepository = historyRepository,
localMangaRepository = localMangaRepository,
@@ -256,28 +255,35 @@ class DetailsViewModel @AssistedInject constructor(
}
}
fun updateScrobbling(rating: Float, status: ScrobblingStatus?) {
for (scrobbler in scrobblers) {
if (!scrobbler.isAvailable) continue
launchJob(Dispatchers.Default) {
scrobbler.updateScrobblingInfo(
mangaId = delegate.mangaId,
rating = rating,
status = status,
comment = null,
)
}
fun updateScrobbling(index: Int, rating: Float, status: ScrobblingStatus?) {
val scrobbler = getScrobbler(index) ?: return
launchJob(Dispatchers.Default) {
scrobbler.updateScrobblingInfo(
mangaId = delegate.mangaId,
rating = rating,
status = status,
comment = null,
)
}
}
fun unregisterScrobbling() {
for (scrobbler in scrobblers) {
if (!scrobbler.isAvailable) continue
launchJob(Dispatchers.Default) {
scrobbler.unregisterScrobbling(
mangaId = delegate.mangaId,
)
}
fun unregisterScrobbling(index: Int) {
val scrobbler = getScrobbler(index) ?: return
launchJob(Dispatchers.Default) {
scrobbler.unregisterScrobbling(
mangaId = delegate.mangaId,
)
}
}
fun markChapterAsCurrent(chapterId: Long) {
launchJob(Dispatchers.Default) {
val manga = checkNotNull(delegate.manga.value)
val chapters = checkNotNull(manga.chapters)
val chapterIndex = chapters.indexOfFirst { it.id == chapterId }
check(chapterIndex in chapters.indices) { "Chapter not found" }
val percent = chapterIndex / chapters.size.toFloat()
historyRepository.addOrUpdate(manga = manga, chapterId = chapterId, page = 0, scroll = 0, percent = percent)
}
}
@@ -303,6 +309,19 @@ class DetailsViewModel @AssistedInject constructor(
return spannable.trim()
}
private fun getScrobbler(index: Int): Scrobbler? {
val info = scrobblingInfo.value?.getOrNull(index)
val scrobbler = if (info != null) {
scrobblers.find { it.scrobblerService == info.scrobbler && it.isAvailable }
} else {
null
}
if (scrobbler == null) {
errorEvent.call(IllegalStateException("Scrobbler [$index] is not available"))
}
return scrobbler
}
@AssistedFactory
interface Factory {

View File

@@ -7,7 +7,6 @@ import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.history.domain.HistoryRepository
@@ -21,7 +20,6 @@ import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
class MangaDetailsDelegate(
private val intent: MangaIntent,
private val settings: AppSettings,
private val mangaDataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository,
private val localMangaRepository: LocalMangaRepository,
@@ -82,7 +80,6 @@ class MangaDetailsDelegate(
branch: String?,
): List<ChapterListItem> {
val result = ArrayList<ChapterListItem>(chapters.size)
val dateFormat = settings.getDateFormat()
val currentIndex = chapters.indexOfFirst { it.id == currentId }
val firstNewIndex = chapters.size - newCount
val downloadedIds = downloadedChapters?.mapTo(HashSet(downloadedChapters.size)) { it.id }
@@ -97,7 +94,6 @@ class MangaDetailsDelegate(
isNew = i >= firstNewIndex,
isMissing = false,
isDownloaded = downloadedIds?.contains(chapter.id) == true,
dateFormat = dateFormat,
)
}
if (result.size < chapters.size / 2) {
@@ -117,7 +113,6 @@ class MangaDetailsDelegate(
val result = ArrayList<ChapterListItem>(sourceChapters.size)
val currentIndex = sourceChapters.indexOfFirst { it.id == currentId }
val firstNewIndex = sourceChapters.size - newCount
val dateFormat = settings.getDateFormat()
for (i in sourceChapters.indices) {
val chapter = sourceChapters[i]
val localChapter = chaptersMap.remove(chapter.id)
@@ -130,14 +125,12 @@ class MangaDetailsDelegate(
isNew = i >= firstNewIndex,
isMissing = false,
isDownloaded = false,
dateFormat = dateFormat,
) ?: chapter.toListItem(
isCurrent = i == currentIndex,
isUnread = i > currentIndex,
isNew = i >= firstNewIndex,
isMissing = true,
isDownloaded = false,
dateFormat = dateFormat,
)
}
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
@@ -150,7 +143,6 @@ class MangaDetailsDelegate(
isNew = false,
isMissing = false,
isDownloaded = false,
dateFormat = dateFormat,
)
} else {
null

View File

@@ -1,21 +1,24 @@
package org.koitharu.kotatsu.details.ui.model
import java.text.DateFormat
import android.text.format.DateUtils
import org.koitharu.kotatsu.parsers.model.MangaChapter
class ChapterListItem(
val chapter: MangaChapter,
val flags: Int,
private val uploadDateMs: Long,
private val dateFormat: DateFormat,
) {
var uploadDate: String? = null
var uploadDate: CharSequence? = null
private set
get() {
if (field != null) return field
if (uploadDateMs == 0L) return null
field = dateFormat.format(uploadDateMs)
field = DateUtils.getRelativeTimeSpanString(
uploadDateMs,
System.currentTimeMillis(),
DateUtils.DAY_IN_MILLIS,
)
return field
}
@@ -44,7 +47,6 @@ class ChapterListItem(
if (chapter != other.chapter) return false
if (flags != other.flags) return false
if (uploadDateMs != other.uploadDateMs) return false
if (dateFormat != other.dateFormat) return false
return true
}
@@ -53,7 +55,6 @@ class ChapterListItem(
var result = chapter.hashCode()
result = 31 * result + flags
result = 31 * result + uploadDateMs.hashCode()
result = 31 * result + dateFormat.hashCode()
return result
}

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.details.ui.model
import java.text.DateFormat
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_MISSING
@@ -14,7 +13,6 @@ fun MangaChapter.toListItem(
isNew: Boolean,
isMissing: Boolean,
isDownloaded: Boolean,
dateFormat: DateFormat,
): ChapterListItem {
var flags = 0
if (isCurrent) flags = flags or FLAG_CURRENT
@@ -26,6 +24,5 @@ fun MangaChapter.toListItem(
chapter = this,
flags = flags,
uploadDateMs = uploadDate,
dateFormat = dateFormat,
)
}

View File

@@ -6,7 +6,7 @@ import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemScrobblingInfoBinding
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
@@ -23,7 +23,7 @@ fun scrobblingInfoAD(
}
bind {
binding.imageViewCover.newImageRequest(item.coverUrl)?.run {
binding.imageViewCover.newImageRequest(item.coverUrl /* TODO */, null)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)

View File

@@ -15,18 +15,21 @@ import androidx.core.net.toUri
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import coil.ImageLoader
import coil.request.ImageRequest
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.databinding.SheetScrobblingBinding
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorBottomSheet
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorBottomSheet
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
@AndroidEntryPoint
class ScrobblingInfoBottomSheet :
@@ -41,6 +44,7 @@ class ScrobblingInfoBottomSheet :
@Inject
lateinit var coil: ImageLoader
private var menu: PopupMenu? = null
override fun onCreate(savedInstanceState: Bundle?) {
@@ -78,6 +82,7 @@ class ScrobblingInfoBottomSheet :
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
viewModel.updateScrobbling(
index = scrobblerIndex,
rating = binding.ratingBar.rating / binding.ratingBar.numStars,
status = enumValues<ScrobblingStatus>().getOrNull(position),
)
@@ -88,6 +93,7 @@ class ScrobblingInfoBottomSheet :
override fun onRatingChanged(ratingBar: RatingBar, rating: Float, fromUser: Boolean) {
if (fromUser) {
viewModel.updateScrobbling(
index = scrobblerIndex,
rating = rating / ratingBar.numStars,
status = enumValues<ScrobblingStatus>().getOrNull(binding.spinnerStatus.selectedItemPosition),
)
@@ -100,7 +106,7 @@ class ScrobblingInfoBottomSheet :
R.id.imageView_cover -> {
val coverUrl = viewModel.scrobblingInfo.value?.getOrNull(scrobblerIndex)?.coverUrl ?: return
val options = scaleUpActivityOptionsOf(v)
startActivity(ImageActivity.newIntent(v.context, coverUrl), options.toBundle())
startActivity(ImageActivity.newIntent(v.context, coverUrl, null), options.toBundle())
}
}
}
@@ -115,15 +121,15 @@ class ScrobblingInfoBottomSheet :
binding.ratingBar.rating = scrobbling.rating * binding.ratingBar.numStars
binding.textViewDescription.text = scrobbling.description
binding.spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1)
ImageRequest.Builder(context ?: return)
.target(binding.imageViewCover)
.data(scrobbling.coverUrl)
.crossfade(context)
.lifecycle(viewLifecycleOwner)
.placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_error_placeholder)
.enqueueWith(coil)
binding.imageViewLogo.contentDescription = getString(scrobbling.scrobbler.titleResId)
binding.imageViewLogo.setImageResource(scrobbling.scrobbler.iconResId)
binding.imageViewCover.newImageRequest(scrobbling.coverUrl)?.apply {
lifecycle(viewLifecycleOwner)
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
enqueueWith(coil)
}
}
override fun onMenuItemClick(item: MenuItem): Boolean {
@@ -135,13 +141,16 @@ class ScrobblingInfoBottomSheet :
Intent.createChooser(intent, getString(R.string.open_in_browser)),
)
}
R.id.action_unregister -> {
viewModel.unregisterScrobbling()
viewModel.unregisterScrobbling(scrobblerIndex)
dismiss()
}
R.id.action_edit -> {
val manga = viewModel.manga.value ?: return false
ScrobblingSelectorBottomSheet.show(parentFragmentManager, manga)
val scrobblerService = viewModel.scrobblingInfo.value?.getOrNull(scrobblerIndex)?.scrobbler
ScrobblingSelectorBottomSheet.show(parentFragmentManager, manga, scrobblerService)
dismiss()
}
}

View File

@@ -5,7 +5,7 @@ import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
class ScrollingInfoAdapter(
lifecycleOwner: LifecycleOwner,
@@ -27,7 +27,7 @@ class ScrollingInfoAdapter(
return oldItem == newItem
}
override fun getChangePayload(oldItem: ScrobblingInfo, newItem: ScrobblingInfo): Any? {
override fun getChangePayload(oldItem: ScrobblingInfo, newItem: ScrobblingInfo): Any {
return Unit
}
}

View File

@@ -38,7 +38,6 @@ import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
import java.io.File
@@ -118,7 +117,7 @@ class DownloadManager @AssistedInject constructor(
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = CbzMangaOutput.get(destination, data)
val coverUrl = data.largeCoverUrl ?: data.coverUrl
downloadFile(coverUrl, data.publicUrl, destination, tempFileName).let { file ->
downloadFile(coverUrl, data.publicUrl, destination, tempFileName, repo.source).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
}
val chapters = checkNotNull(
@@ -139,7 +138,8 @@ class DownloadManager @AssistedInject constructor(
for ((pageIndex, page) in pages.withIndex()) {
runFailsafe(outState, pausingHandle) {
val url = repo.getPageUrl(page)
val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
val file = cache[url]
?: downloadFile(url, page.referer, destination, tempFileName, repo.source)
output.addPage(
chapter = chapter,
file = file,
@@ -209,10 +209,17 @@ class DownloadManager @AssistedInject constructor(
}
}
private suspend fun downloadFile(url: String, referer: String, destination: File, tempFileName: String): File {
private suspend fun downloadFile(
url: String,
referer: String,
destination: File,
tempFileName: String,
source: MangaSource,
): File {
val request = Request.Builder()
.url(url)
.header(CommonHeaders.REFERER, referer)
.tag(MangaSource::class.java, source)
.cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
.get()
.build()
@@ -242,7 +249,7 @@ class DownloadManager @AssistedInject constructor(
imageLoader.execute(
ImageRequest.Builder(context)
.data(manga.coverUrl)
.referer(manga.publicUrl)
.tag(manga.source)
.size(coverWidth, coverHeight)
.scale(Scale.FILL)
.build(),

View File

@@ -9,6 +9,13 @@ sealed interface DownloadState {
val manga: Manga
val cover: Drawable?
override fun equals(other: Any?): Boolean
override fun hashCode(): Int
val isTerminal: Boolean
get() = this is Done || this is Cancelled || (this is Error && !canRetry)
class Queued(
override val startId: Int,
override val manga: Manga,

View File

@@ -13,7 +13,10 @@ import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.onFirst
fun downloadItemAD(
scope: CoroutineScope,
@@ -40,8 +43,7 @@ fun downloadItemAD(
bind {
job?.cancel()
job = item.progressAsFlow().onFirst { state ->
binding.imageViewCover.newImageRequest(state.manga.coverUrl)?.run {
referer(state.manga.publicUrl)
binding.imageViewCover.newImageRequest(state.manga.coverUrl, state.manga.source)?.run {
placeholder(state.cover)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
@@ -60,6 +62,7 @@ fun downloadItemAD(
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
}
is DownloadState.Done -> {
binding.textViewStatus.setText(R.string.download_complete)
binding.progressBar.isIndeterminate = false
@@ -69,6 +72,7 @@ fun downloadItemAD(
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
}
is DownloadState.Error -> {
binding.textViewStatus.setText(R.string.error_occurred)
binding.progressBar.isIndeterminate = false
@@ -79,6 +83,7 @@ fun downloadItemAD(
binding.buttonCancel.isVisible = state.canRetry
binding.buttonResume.isVisible = state.canRetry
}
is DownloadState.PostProcessing -> {
binding.textViewStatus.setText(R.string.processing_)
binding.progressBar.isIndeterminate = true
@@ -88,6 +93,7 @@ fun downloadItemAD(
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
}
is DownloadState.Preparing -> {
binding.textViewStatus.setText(R.string.preparing_)
binding.progressBar.isIndeterminate = true
@@ -97,6 +103,7 @@ fun downloadItemAD(
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
}
is DownloadState.Progress -> {
binding.textViewStatus.setText(R.string.manga_downloading_)
binding.progressBar.isIndeterminate = false
@@ -109,6 +116,7 @@ fun downloadItemAD(
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
}
is DownloadState.Queued -> {
binding.textViewStatus.setText(R.string.queued)
binding.progressBar.isIndeterminate = false

View File

@@ -1,27 +1,19 @@
package org.koitharu.kotatsu.download.ui
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.download.ui.service.DownloadService
import javax.inject.Inject
@AndroidEntryPoint
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
@@ -29,6 +21,8 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
@Inject
lateinit var coil: ImageLoader
private lateinit var serviceConnection: DownloadsConnection
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
@@ -38,9 +32,12 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter
val connection = DownloadServiceConnection(adapter)
bindService(Intent(this, DownloadService::class.java), connection, 0)
lifecycle.addObserver(connection)
serviceConnection = DownloadsConnection(this, this)
serviceConnection.items.observe(this) { items ->
adapter.items = items
binding.textViewHolder.isVisible = items.isNullOrEmpty()
}
serviceConnection.bind()
}
override fun onWindowInsetsChanged(insets: Insets) {
@@ -55,46 +52,6 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
)
}
private inner class DownloadServiceConnection(
private val adapter: DownloadsAdapter,
) : ServiceConnection, DefaultLifecycleObserver {
private var collectJob: Job? = null
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
collectJob?.cancel()
val binder = (service as? DownloadService.DownloadBinder)
collectJob = if (binder == null) {
null
} else {
lifecycleScope.launch {
binder.downloads.collect {
setItems(it)
}
}
}
}
override fun onServiceDisconnected(name: ComponentName?) {
collectJob?.cancel()
collectJob = null
setItems(null)
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
collectJob?.cancel()
collectJob = null
owner.lifecycle.removeObserver(this)
unbindService(this)
}
private fun setItems(items: Collection<DownloadItem>?) {
adapter.items = items?.toList().orEmpty()
binding.textViewHolder.isVisible = items.isNullOrEmpty()
}
}
companion object {
fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java)

View File

@@ -0,0 +1,76 @@
package org.koitharu.kotatsu.download.ui
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
class DownloadsConnection(
private val context: Context,
private val lifecycleOwner: LifecycleOwner,
) : ServiceConnection {
private var bindingObserver: BindingLifecycleObserver? = null
private var collectJob: Job? = null
private val itemsFlow = MutableStateFlow<List<PausingProgressJob<DownloadState>>>(emptyList())
val items
get() = itemsFlow.asFlowLiveData()
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
collectJob?.cancel()
val binder = (service as? DownloadService.DownloadBinder)
collectJob = if (binder == null) {
null
} else {
lifecycleOwner.lifecycleScope.launch {
binder.downloads.collect {
itemsFlow.value = it
}
}
}
}
override fun onServiceDisconnected(name: ComponentName?) {
collectJob?.cancel()
collectJob = null
itemsFlow.value = itemsFlow.value.filter { it.progressValue.isTerminal }
}
fun bind() {
if (bindingObserver != null) {
return
}
bindingObserver = BindingLifecycleObserver().also {
lifecycleOwner.lifecycle.addObserver(it)
}
context.bindService(Intent(context, DownloadService::class.java), this, 0)
}
fun unbind() {
bindingObserver?.let {
lifecycleOwner.lifecycle.removeObserver(it)
}
bindingObserver = null
context.unbindService(this)
}
private inner class BindingLifecycleObserver : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
unbind()
}
}
}

View File

@@ -7,7 +7,7 @@ import android.content.IntentFilter
import android.os.Binder
import android.os.IBinder
import android.os.PowerManager
import android.widget.Toast
import android.view.View
import androidx.annotation.MainThread
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
@@ -15,6 +15,7 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -29,6 +30,7 @@ import org.koitharu.kotatsu.base.ui.BaseService
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.DownloadsActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.utils.ext.throttle
@@ -66,7 +68,7 @@ class DownloadService : BaseService() {
val intentFilter = IntentFilter()
intentFilter.addAction(ACTION_DOWNLOAD_CANCEL)
intentFilter.addAction(ACTION_DOWNLOAD_RESUME)
registerReceiver(controlReceiver, intentFilter)
ContextCompat.registerReceiver(this, controlReceiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -155,9 +157,6 @@ class DownloadService : BaseService() {
!state.isTerminal
}
private val DownloadState.isTerminal: Boolean
get() = this is DownloadState.Done || this is DownloadState.Cancelled || (this is DownloadState.Error && !canRetry)
@MainThread
private fun stopSelfIfIdle() {
if (jobs.any { (_, job) -> job.isActive }) {
@@ -221,37 +220,38 @@ class DownloadService : BaseService() {
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
private const val EXTRA_CANCEL_ID = "cancel_id"
fun start(context: Context, manga: Manga, chaptersIds: Collection<Long>? = null) {
fun start(view: View, manga: Manga, chaptersIds: Collection<Long>? = null) {
if (chaptersIds?.isEmpty() == true) {
return
}
val intent = Intent(context, DownloadService::class.java)
val intent = Intent(view.context, DownloadService::class.java)
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
if (chaptersIds != null) {
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
}
ContextCompat.startForegroundService(context, intent)
Toast.makeText(context, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
ContextCompat.startForegroundService(view.context, intent)
showStartedSnackbar(view)
}
fun start(context: Context, manga: Collection<Manga>) {
fun start(view: View, manga: Collection<Manga>) {
if (manga.isEmpty()) {
return
}
for (item in manga) {
val intent = Intent(context, DownloadService::class.java)
val intent = Intent(view.context, DownloadService::class.java)
intent.putExtra(EXTRA_MANGA, ParcelableManga(item, withChapters = false))
ContextCompat.startForegroundService(context, intent)
ContextCompat.startForegroundService(view.context, intent)
}
showStartedSnackbar(view)
}
fun confirmAndStart(context: Context, items: Set<Manga>) {
MaterialAlertDialogBuilder(context)
fun confirmAndStart(view: View, items: Set<Manga>) {
MaterialAlertDialogBuilder(view.context)
.setTitle(R.string.save_manga)
.setMessage(R.string.batch_manga_save_confirm)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.save) { _, _ ->
start(context, items)
start(view, items)
}.show()
}
@@ -267,5 +267,12 @@ class DownloadService : BaseService() {
}
return null
}
private fun showStartedSnackbar(view: View) {
Snackbar.make(view, R.string.download_started, Snackbar.LENGTH_LONG)
.setAction(R.string.details) {
it.context.startActivity(DownloadsActivity.newIntent(it.context))
}.show()
}
}
}

View File

@@ -9,6 +9,8 @@ import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
@@ -19,6 +21,7 @@ import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.base.ui.util.SpanSizeResolver
import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity
import org.koitharu.kotatsu.databinding.FragmentExploreBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
@@ -33,6 +36,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import javax.inject.Inject
@@ -63,15 +67,18 @@ class ExploreFragment :
with(binding.recyclerView) {
adapter = exploreAdapter
setHasFixedSize(true)
SpanSizeResolver(this, resources.getDimensionPixelSize(R.dimen.explore_grid_width)).attach()
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
paddingHorizontal = spacing
}
addMenuProvider(ExploreMenuProvider(view.context, viewModel))
viewModel.content.observe(viewLifecycleOwner) {
exploreAdapter?.items = it
}
viewModel.onError.observe(viewLifecycleOwner, ::onError)
viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga)
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged)
}
override fun onDestroyView() {
@@ -149,6 +156,17 @@ class ExploreFragment :
snackbar.show()
}
private fun onGridModeChanged(isGrid: Boolean) {
binding.recyclerView.layoutManager = if (isGrid) {
GridLayoutManager(requireContext(), 4).also { lm ->
lm.spanSizeLookup = ExploreGridSpanSizeLookup(checkNotNull(exploreAdapter), lm)
}
} else {
LinearLayoutManager(requireContext())
}
activity?.invalidateOptionsMenu()
}
private inner class SourceMenuListener(
private val sourceItem: ExploreItem.Source,
) : PopupMenu.OnMenuItemClickListener {

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.explore.ui
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup
import org.koitharu.kotatsu.explore.ui.adapter.ExploreAdapter
class ExploreGridSpanSizeLookup(
private val adapter: ExploreAdapter,
private val layoutManager: GridLayoutManager,
) : SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
val itemType = adapter.getItemViewType(position)
return if (itemType == ExploreAdapter.ITEM_TYPE_SOURCE_GRID) 1 else layoutManager.spanCount
}
}

View File

@@ -0,0 +1,34 @@
package org.koitharu.kotatsu.explore.ui
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
class ExploreMenuProvider(
private val context: Context,
private val viewModel: ExploreViewModel,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_explore, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_grid -> {
viewModel.setGridMode(!menuItem.isChecked)
true
}
else -> false
}
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.action_grid)?.isChecked = viewModel.isGrid.value == true
}
}

View File

@@ -5,22 +5,26 @@ import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.explore.ui.model.ExploreItem
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import javax.inject.Inject
@@ -30,8 +34,15 @@ class ExploreViewModel @Inject constructor(
private val exploreRepository: ExploreRepository,
) : BaseViewModel() {
private val gridMode = settings.observeAsStateFlow(
key = AppSettings.KEY_SOURCES_GRID,
scope = viewModelScope + Dispatchers.IO,
valueProducer = { isSourcesGridMode },
)
val onOpenManga = SingleLiveEvent<Manga>()
val onActionDone = SingleLiveEvent<ReversibleAction>()
val isGrid = gridMode.asFlowLiveData(viewModelScope.coroutineContext)
val content: LiveData<List<ExploreItem>> = isLoading.asFlow().flatMapLatest { loading ->
if (loading) {
@@ -58,6 +69,10 @@ class ExploreViewModel @Inject constructor(
}
}
fun setGridMode(value: Boolean) {
settings.isSourcesGridMode = value
}
private fun createContentFlow() = settings.observe()
.filter {
it == AppSettings.KEY_SOURCES_HIDDEN ||
@@ -67,16 +82,16 @@ class ExploreViewModel @Inject constructor(
.onStart { emit("") }
.map { settings.getMangaSources(includeHidden = false) }
.distinctUntilChanged()
.map { buildList(it) }
.combine(gridMode) { content, grid -> buildList(content, grid) }
private fun buildList(sources: List<MangaSource>): List<ExploreItem> {
private fun buildList(sources: List<MangaSource>, isGrid: Boolean): List<ExploreItem> {
val result = ArrayList<ExploreItem>(sources.size + 3)
result += ExploreItem.Buttons(
isSuggestionsEnabled = settings.isSuggestionsEnabled,
)
result += ExploreItem.Header(R.string.remote_sources, sources.isNotEmpty())
if (sources.isNotEmpty()) {
sources.mapTo(result) { ExploreItem.Source(it) }
sources.mapTo(result) { ExploreItem.Source(it, isGrid) }
} else {
result += ExploreItem.EmptyHint(
icon = R.drawable.ic_empty_common,

View File

@@ -11,11 +11,25 @@ class ExploreAdapter(
lifecycleOwner: LifecycleOwner,
listener: ExploreListEventListener,
clickListener: OnListItemClickListener<ExploreItem.Source>,
) : AsyncListDifferDelegationAdapter<ExploreItem>(
ExploreDiffCallback(),
exploreButtonsAD(listener),
exploreSourcesHeaderAD(listener),
exploreSourceItemAD(coil, clickListener, lifecycleOwner),
exploreEmptyHintListAD(listener),
exploreLoadingAD(),
)
) : AsyncListDifferDelegationAdapter<ExploreItem>(ExploreDiffCallback()) {
init {
delegatesManager
.addDelegate(ITEM_TYPE_BUTTONS, exploreButtonsAD(listener))
.addDelegate(ITEM_TYPE_HEADER, exploreSourcesHeaderAD(listener))
.addDelegate(ITEM_TYPE_SOURCE_LIST, exploreSourceListItemAD(coil, clickListener, lifecycleOwner))
.addDelegate(ITEM_TYPE_SOURCE_GRID, exploreSourceGridItemAD(coil, clickListener, lifecycleOwner))
.addDelegate(ITEM_TYPE_HINT, exploreEmptyHintListAD(listener))
.addDelegate(ITEM_TYPE_LOADING, exploreLoadingAD())
}
companion object {
const val ITEM_TYPE_BUTTONS = 0
const val ITEM_TYPE_HEADER = 1
const val ITEM_TYPE_SOURCE_LIST = 2
const val ITEM_TYPE_SOURCE_GRID = 3
const val ITEM_TYPE_HINT = 4
const val ITEM_TYPE_LOADING = 5
}
}

View File

@@ -12,7 +12,8 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding
import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding
import org.koitharu.kotatsu.databinding.ItemExploreSourceBinding
import org.koitharu.kotatsu.databinding.ItemExploreSourceGridBinding
import org.koitharu.kotatsu.databinding.ItemExploreSourceListBinding
import org.koitharu.kotatsu.databinding.ItemHeaderButtonBinding
import org.koitharu.kotatsu.explore.ui.model.ExploreItem
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
@@ -25,7 +26,7 @@ import org.koitharu.kotatsu.utils.image.FaviconFallbackDrawable
fun exploreButtonsAD(
clickListener: View.OnClickListener,
) = adapterDelegateViewBinding<ExploreItem.Buttons, ExploreItem, ItemExploreButtonsBinding>(
{ layoutInflater, parent -> ItemExploreButtonsBinding.inflate(layoutInflater, parent, false) }
{ layoutInflater, parent -> ItemExploreButtonsBinding.inflate(layoutInflater, parent, false) },
) {
binding.buttonBookmarks.setOnClickListener(clickListener)
@@ -43,7 +44,7 @@ fun exploreButtonsAD(
fun exploreSourcesHeaderAD(
listener: ExploreListEventListener,
) = adapterDelegateViewBinding<ExploreItem.Header, ExploreItem, ItemHeaderButtonBinding>(
{ layoutInflater, parent -> ItemHeaderButtonBinding.inflate(layoutInflater, parent, false) }
{ layoutInflater, parent -> ItemHeaderButtonBinding.inflate(layoutInflater, parent, false) },
) {
val listenerAdapter = View.OnClickListener {
@@ -58,13 +59,44 @@ fun exploreSourcesHeaderAD(
}
}
fun exploreSourceItemAD(
fun exploreSourceListItemAD(
coil: ImageLoader,
listener: OnListItemClickListener<ExploreItem.Source>,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<ExploreItem.Source, ExploreItem, ItemExploreSourceBinding>(
{ layoutInflater, parent -> ItemExploreSourceBinding.inflate(layoutInflater, parent, false) },
on = { item, _, _ -> item is ExploreItem.Source }
) = adapterDelegateViewBinding<ExploreItem.Source, ExploreItem, ItemExploreSourceListBinding>(
{ layoutInflater, parent -> ItemExploreSourceListBinding.inflate(layoutInflater, parent, false) },
on = { item, _, _ -> item is ExploreItem.Source && !item.isGrid },
) {
val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
binding.root.setOnClickListener(eventListener)
binding.root.setOnLongClickListener(eventListener)
bind {
binding.textViewTitle.text = item.source.title
val fallbackIcon = FaviconFallbackDrawable(context, item.source.name)
binding.imageViewIcon.newImageRequest(item.source.faviconUri(), item.source)?.run {
fallback(fallbackIcon)
placeholder(fallbackIcon)
error(fallbackIcon)
lifecycle(lifecycleOwner)
enqueueWith(coil)
}
}
onViewRecycled {
binding.imageViewIcon.disposeImageRequest()
}
}
fun exploreSourceGridItemAD(
coil: ImageLoader,
listener: OnListItemClickListener<ExploreItem.Source>,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<ExploreItem.Source, ExploreItem, ItemExploreSourceGridBinding>(
{ layoutInflater, parent -> ItemExploreSourceGridBinding.inflate(layoutInflater, parent, false) },
on = { item, _, _ -> item is ExploreItem.Source && item.isGrid },
) {
val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
@@ -92,7 +124,7 @@ fun exploreSourceItemAD(
fun exploreEmptyHintListAD(
listener: ListStateHolderListener,
) = adapterDelegateViewBinding<ExploreItem.EmptyHint, ExploreItem, ItemEmptyCardBinding>(
{ inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) }
{ inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) },
) {
binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() }

View File

@@ -12,11 +12,13 @@ class ExploreDiffCallback : DiffUtil.ItemCallback<ExploreItem>() {
oldItem is ExploreItem.Loading && newItem is ExploreItem.Loading -> true
oldItem is ExploreItem.EmptyHint && newItem is ExploreItem.EmptyHint -> true
oldItem is ExploreItem.Source && newItem is ExploreItem.Source -> {
oldItem.source == newItem.source
oldItem.source == newItem.source && oldItem.isGrid == newItem.isGrid
}
oldItem is ExploreItem.Header && newItem is ExploreItem.Header -> {
oldItem.titleResId == newItem.titleResId
}
else -> false
}
}
@@ -24,4 +26,4 @@ class ExploreDiffCallback : DiffUtil.ItemCallback<ExploreItem>() {
override fun areContentsTheSame(oldItem: ExploreItem, newItem: ExploreItem): Boolean {
return oldItem == newItem
}
}
}

View File

@@ -54,6 +54,7 @@ sealed interface ExploreItem : ListModel {
class Source(
val source: MangaSource,
val isGrid: Boolean,
) : ExploreItem {
override fun equals(other: Any?): Boolean {
@@ -63,12 +64,15 @@ sealed interface ExploreItem : ListModel {
other as Source
if (source != other.source) return false
if (isGrid != other.isGrid) return false
return true
}
override fun hashCode(): Int {
return source.hashCode()
var result = source.hashCode()
result = 31 * result + isGrid.hashCode()
return result
}
}
@@ -80,5 +84,8 @@ sealed interface ExploreItem : ListModel {
@StringRes actionStringRes: Int,
) : EmptyState(icon, textPrimary, textSecondary, actionStringRes), ExploreItem
object Loading : ExploreItem
object Loading : ExploreItem {
override fun equals(other: Any?): Boolean = other === Loading
}
}

View File

@@ -21,9 +21,6 @@ abstract class FavouriteCategoriesDao {
@Insert(onConflict = OnConflictStrategy.ABORT)
abstract suspend fun insert(category: FavouriteCategoryEntity): Long
@Update
abstract suspend fun update(category: FavouriteCategoryEntity): Int
suspend fun delete(id: Long) = setDeletedAt(id, System.currentTimeMillis())
@Query("UPDATE favourite_categories SET title = :title, `order` = :order, `track` = :tracker WHERE category_id = :id")
@@ -51,12 +48,8 @@ abstract class FavouriteCategoriesDao {
return (getMaxSortKey() ?: 0) + 1
}
@Transaction
open suspend fun upsert(entity: FavouriteCategoryEntity) {
if (update(entity) == 0) {
insert(entity)
}
}
@Upsert
abstract suspend fun upsert(entity: FavouriteCategoryEntity)
@Query("UPDATE favourite_categories SET deleted_at = :deletedAt WHERE category_id = :id")
protected abstract suspend fun setDeletedAt(id: Long, deletedAt: Long)

View File

@@ -99,11 +99,6 @@ abstract class FavouritesDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(favourite: FavouriteEntity)
/** UPDATE **/
@Update
abstract suspend fun update(favourite: FavouriteEntity): Int
/** DELETE **/
suspend fun delete(mangaId: Long) = setDeletedAt(
@@ -138,12 +133,8 @@ abstract class FavouritesDao {
/** TOOLS **/
@Transaction
open suspend fun upsert(entity: FavouriteEntity) {
if (update(entity) == 0) {
insert(entity)
}
}
@Upsert
abstract suspend fun upsert(entity: FavouriteEntity)
@Transaction
@RawQuery(observedEntities = [FavouriteEntity::class])
@@ -166,6 +157,7 @@ abstract class FavouritesDao {
SortOrder.NEWEST,
SortOrder.UPDATED,
-> "created_at DESC"
SortOrder.ALPHABETICAL -> "title ASC"
else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
}

View File

@@ -9,6 +9,7 @@ import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
@@ -19,7 +20,6 @@ import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.ListSelectionController
@@ -32,8 +32,8 @@ import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import javax.inject.Inject
@AndroidEntryPoint
class FavouriteCategoriesActivity :
@@ -47,6 +47,7 @@ class FavouriteCategoriesActivity :
private val viewModel by viewModels<FavouritesCategoriesViewModel>()
private lateinit var exitReorderModeCallback: ExitReorderModeCallback
private lateinit var adapter: CategoriesAdapter
private lateinit var selectionController: ListSelectionController
private var reorderHelper: ItemTouchHelper? = null
@@ -55,6 +56,7 @@ class FavouriteCategoriesActivity :
super.onCreate(savedInstanceState)
setContentView(ActivityCategoriesBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
exitReorderModeCallback = ExitReorderModeCallback(viewModel)
adapter = CategoriesAdapter(coil, this, this, this)
selectionController = ListSelectionController(
activity = this,
@@ -67,6 +69,7 @@ class FavouriteCategoriesActivity :
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter
binding.fabAdd.setOnClickListener(this)
onBackPressedDispatcher.addCallback(exitReorderModeCallback)
viewModel.detalizedCategories.observe(this, ::onCategoriesChanged)
viewModel.onError.observe(this, ::onError)
@@ -90,15 +93,8 @@ class FavouriteCategoriesActivity :
viewModel.setReorderMode(true)
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onBackPressed() {
if (viewModel.isInReorderMode()) {
viewModel.setReorderMode(false)
} else {
super.onBackPressed()
else -> super.onOptionsItemSelected(item)
}
}
@@ -138,7 +134,7 @@ class FavouriteCategoriesActivity :
}
binding.root.updatePadding(
left = insets.left,
right = insets.right
right = insets.right,
)
binding.recyclerView.updatePadding(
bottom = insets.bottom,
@@ -174,6 +170,7 @@ class FavouriteCategoriesActivity :
binding.recyclerView.isNestedScrollingEnabled = !isReorderMode
invalidateOptionsMenu()
binding.buttonDone.isVisible = isReorderMode
exitReorderModeCallback.isEnabled = isReorderMode
}
private inner class ReorderHelperCallback : ItemTouchHelper.SimpleCallback(
@@ -211,6 +208,15 @@ class FavouriteCategoriesActivity :
override fun isLongPressDragEnabled(): Boolean = false
}
private class ExitReorderModeCallback(
private val viewModel: FavouritesCategoriesViewModel,
) : OnBackPressedCallback(viewModel.isInReorderMode()) {
override fun handleOnBackPressed() {
viewModel.setReorderMode(false)
}
}
companion object {
val SORT_ORDERS = arrayOf(

View File

@@ -5,7 +5,9 @@ import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.MotionEvent
import android.view.View
import android.view.View.*
import android.view.View.OnClickListener
import android.view.View.OnLongClickListener
import android.view.View.OnTouchListener
import androidx.core.graphics.ColorUtils
import androidx.core.view.isVisible
import androidx.core.widget.ImageViewCompat
@@ -16,7 +18,11 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemCategoryBinding
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.ext.animatorDurationScale
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getThemeColor
import org.koitharu.kotatsu.utils.ext.newImageRequest
fun categoryAD(
coil: ImageLoader,

View File

@@ -22,8 +22,8 @@ import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.toMangaHistory
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.domain.tryScrobble
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.tryScrobble
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.mapItems
import javax.inject.Inject

View File

@@ -17,11 +17,12 @@ import coil.target.ViewTarget
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityImageBinding
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.indicator
import javax.inject.Inject
@AndroidEntryPoint
class ImageActivity : BaseActivity<ActivityImageBinding>() {
@@ -56,6 +57,7 @@ class ImageActivity : BaseActivity<ActivityImageBinding>() {
.data(url)
.memoryCachePolicy(CachePolicy.DISABLED)
.lifecycle(this)
.tag(intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource)
.target(SsivTarget(binding.ssiv))
.indicator(binding.progressBar)
.enqueueWith(coil)
@@ -88,9 +90,12 @@ class ImageActivity : BaseActivity<ActivityImageBinding>() {
companion object {
fun newIntent(context: Context, url: String): Intent {
private const val EXTRA_SOURCE = "source"
fun newIntent(context: Context, url: String, source: MangaSource?): Intent {
return Intent(context, ImageActivity::class.java)
.setData(Uri.parse(url))
.putExtra(EXTRA_SOURCE, source)
}
}
}

View File

@@ -177,7 +177,7 @@ abstract class MangaListFragment :
private fun onError(e: Throwable) {
if (e is CloudFlareProtectedException) {
CloudFlareDialog.newInstance(e.url).show(childFragmentManager, CloudFlareDialog.TAG)
CloudFlareDialog.newInstance(e.url, e.headers).show(childFragmentManager, CloudFlareDialog.TAG)
} else {
Snackbar.make(
binding.recyclerView,
@@ -327,7 +327,7 @@ abstract class MangaListFragment :
}
R.id.action_save -> {
DownloadService.confirmAndStart(requireContext(), selectedItems)
DownloadService.confirmAndStart(binding.recyclerView, selectedItems)
mode.finish()
true
}

View File

@@ -12,17 +12,22 @@ import org.koitharu.kotatsu.utils.ext.setTextAndVisible
fun emptyStateListAD(
coil: ImageLoader,
listener: ListStateHolderListener,
listener: ListStateHolderListener?,
) = adapterDelegateViewBinding<EmptyState, ListModel, ItemEmptyStateBinding>(
{ inflater, parent -> ItemEmptyStateBinding.inflate(inflater, parent, false) },
) {
binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() }
if (listener != null) {
binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() }
}
bind {
binding.icon.newImageRequest(item.icon)?.enqueueWith(coil)
binding.textPrimary.setText(item.textPrimary)
binding.textSecondary.setTextAndVisible(item.textSecondary)
binding.buttonRetry.setTextAndVisible(item.actionStringRes)
if (listener != null) {
binding.buttonRetry.setTextAndVisible(item.actionStringRes)
}
}
onViewRecycled {

View File

@@ -15,7 +15,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
fun mangaGridItemAD(
@@ -39,8 +38,7 @@ fun mangaGridItemAD(
bind { payloads ->
binding.textViewTitle.text = item.title
binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
binding.imageViewCover.newImageRequest(item.coverUrl)?.run {
referer(item.manga.publicUrl)
binding.imageViewCover.newImageRequest(item.coverUrl, item.source)?.run {
size(CoverSizeResolver(binding.imageViewCover))
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)

View File

@@ -17,7 +17,6 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.textAndVisible
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
@@ -52,8 +51,7 @@ fun mangaListDetailedItemAD(
binding.textViewTitle.text = item.title
binding.textViewSubtitle.textAndVisible = item.subtitle
binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
binding.imageViewCover.newImageRequest(item.coverUrl)?.run {
referer(item.manga.publicUrl)
binding.imageViewCover.newImageRequest(item.coverUrl, item.source)?.run {
size(CoverSizeResolver(binding.imageViewCover))
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
@@ -62,6 +60,9 @@ fun mangaListDetailedItemAD(
lifecycle(lifecycleOwner)
enqueueWith(coil)
}
if (payloads.isEmpty()) {
binding.scrollViewTags.scrollTo(0, 0)
}
binding.chipsTags.setChips(item.tags)
binding.ratingBar.isVisible = item.manga.hasRating
binding.ratingBar.rating = binding.ratingBar.numStars * item.manga.rating

View File

@@ -10,7 +10,10 @@ import org.koitharu.kotatsu.databinding.ItemMangaListBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.textAndVisible
fun mangaListItemAD(
coil: ImageLoader,
@@ -31,8 +34,7 @@ fun mangaListItemAD(
bind {
binding.textViewTitle.text = item.title
binding.textViewSubtitle.textAndVisible = item.subtitle
binding.imageViewCover.newImageRequest(item.coverUrl)?.run {
referer(item.manga.publicUrl)
binding.imageViewCover.newImageRequest(item.coverUrl, item.source)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)

View File

@@ -1,34 +1,29 @@
package org.koitharu.kotatsu.list.ui.filter
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.view.*
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.LinearLayoutManager
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.util.CollapseActionViewCallback
import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
import org.koitharu.kotatsu.utils.ext.isScrolledToTop
import org.koitharu.kotatsu.utils.ext.parentFragmentViewModels
class FilterBottomSheet :
BaseBottomSheet<SheetFilterBinding>(),
MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener,
DialogInterface.OnKeyListener,
AsyncListDiffer.ListListener<FilterItem> {
private val viewModel by parentFragmentViewModels<RemoteListViewModel>()
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return super.onCreateDialog(savedInstanceState).also {
it.setOnKeyListener(this)
}
}
private var collapsibleActionViewCallback: CollapseActionViewCallback? = null
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
return SheetFilterBinding.inflate(inflater, container, false)
@@ -42,8 +37,14 @@ class FilterBottomSheet :
initOptionsMenu()
}
override fun onDestroyView() {
super.onDestroyView()
collapsibleActionViewCallback = null
}
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
setExpanded(isExpanded = true, isLocked = true)
collapsibleActionViewCallback?.onMenuItemActionExpand(item)
return true
}
@@ -51,6 +52,7 @@ class FilterBottomSheet :
val searchView = (item.actionView as? SearchView) ?: return false
searchView.setQuery("", false)
searchView.post { setExpanded(isExpanded = false, isLocked = false) }
collapsibleActionViewCallback?.onMenuItemActionCollapse(item)
return true
}
@@ -61,19 +63,6 @@ class FilterBottomSheet :
return true
}
override fun onKey(dialog: DialogInterface?, keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK) {
val menuItem = binding.headerBar.toolbar.menu.findItem(R.id.action_search) ?: return false
if (menuItem.isActionViewExpanded) {
if (event?.action == KeyEvent.ACTION_UP) {
menuItem.collapseActionView()
}
return true
}
}
return false
}
override fun onCurrentListChanged(previousList: MutableList<FilterItem>, currentList: MutableList<FilterItem>) {
if (currentList.size > previousList.size && view != null) {
(binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(0, 0)
@@ -81,13 +70,16 @@ class FilterBottomSheet :
}
private fun initOptionsMenu() {
binding.headerBar.toolbar.inflateMenu(R.menu.opt_filter)
val searchMenuItem = binding.headerBar.toolbar.menu.findItem(R.id.action_search)
binding.headerBar.inflateMenu(R.menu.opt_filter)
val searchMenuItem = binding.headerBar.menu.findItem(R.id.action_search)
searchMenuItem.setOnActionExpandListener(this)
val searchView = searchMenuItem.actionView as SearchView
searchView.setOnQueryTextListener(this)
searchView.setIconifiedByDefault(false)
searchView.queryHint = searchMenuItem.title
collapsibleActionViewCallback = CollapseActionViewCallback(searchMenuItem).also {
onBackPressedDispatcher.addCallback(it)
}
}
companion object {

View File

@@ -1,3 +1,6 @@
package org.koitharu.kotatsu.list.ui.model
interface ListModel
interface ListModel {
override fun equals(other: Any?): Boolean
}

View File

@@ -1,3 +1,6 @@
package org.koitharu.kotatsu.list.ui.model
object LoadingFooter : ListModel
object LoadingFooter : ListModel {
override fun equals(other: Any?): Boolean = other === LoadingFooter
}

View File

@@ -1,3 +1,6 @@
package org.koitharu.kotatsu.list.ui.model
object LoadingState : ListModel
object LoadingState : ListModel {
override fun equals(other: Any?): Boolean = other === LoadingState
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
sealed interface MangaItemModel : ListModel {
@@ -10,4 +11,7 @@ sealed interface MangaItemModel : ListModel {
val coverUrl: String
val counter: Int
val progress: Float
}
val source: MangaSource
get() = manga.source
}

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