Compare commits

..

306 Commits
v7.5 ... v7.7.8

Author SHA1 Message Date
Koitharu
b425f3e779 Fix nullability for ParcelableManga
(cherry picked from commit b8b601821a)
2025-01-19 12:00:38 +02:00
Koitharu
c6a51d4d08 Increase version 2025-01-19 11:56:26 +02:00
Koitharu
503bff292c Made SyncAuthActivity exported
(cherry picked from commit 663602282a)
2025-01-19 08:28:02 +02:00
Koitharu
0aa78c0d7e Adjust manga fields nullability 2025-01-19 08:24:25 +02:00
Koitharu
8e1d02f356 Update parsers 2025-01-19 08:01:09 +02:00
Koitharu
1e90d5541b Update parsers 2025-01-11 15:09:21 +02:00
Koitharu
04c7ca7291 Improve local manga chapter names
(cherry picked from commit dddb00d5ef)
2025-01-11 14:59:38 +02:00
Koitharu
8d52cab6d8 Fix manga importing
(cherry picked from commit dcb92ed1af)
2025-01-11 14:59:33 +02:00
Koitharu
efa13df106 Fix crashes 2025-01-11 14:59:25 +02:00
Koitharu
8bc29ac331 Fix local chapters deletion
(cherry picked from commit 25eb05d305)
2025-01-11 14:58:53 +02:00
Koitharu
7991f9ca97 Skip description for ParcelableManga
(cherry picked from commit bf217b3cbf)
2025-01-11 14:58:44 +02:00
Koitharu
eb1eee1681 Fix pages cache usage
(cherry picked from commit 9e2b60e15e)
2025-01-11 14:57:56 +02:00
Koitharu
b3f748c000 Fix crashes
(cherry picked from commit 4dba90361c)
2025-01-11 14:57:49 +02:00
Koitharu
58a9f7b25a Fix settings menu
(cherry picked from commit c51218240e)
2025-01-11 14:56:13 +02:00
Koitharu
fc1d704f6f Fix build 2025-01-01 14:24:43 +02:00
Koitharu
c2c3b0f757 Fix details cover corners 2025-01-01 14:00:04 +02:00
Koitharu
8d519dd80f Fix settings search 2025-01-01 13:59:59 +02:00
Koitharu
3b5a9cd2b4 Skip non-existing local chapters 2025-01-01 13:59:54 +02:00
Koitharu
95f4d39893 Update parsers 2025-01-01 13:53:17 +02:00
Koitharu
f3f269c7fa Fix NPE in SyncSettings 2024-12-30 10:01:08 +02:00
Koitharu
40f262b0ef Update parsers 2024-12-19 17:28:12 +02:00
Koitharu
0f68be9663 Use advanced bitmap decoder for MangaLoaderContext 2024-12-19 17:10:01 +02:00
Koitharu
0b8afe9c40 Fix checking for new chapters in some cases (#1212, #1195, #1190) 2024-12-18 18:26:27 +02:00
Koitharu
754ccc4197 Added url for NoDataReceivedException 2024-12-18 16:26:49 +02:00
Koitharu
ef691b1aed Update parsers 2024-12-18 15:48:57 +02:00
Koitharu
1bd916371a Update parsers 2024-12-14 09:36:50 +02:00
Koitharu
cd40dab8a4 Error handling fixes 2024-12-10 14:29:55 +02:00
Koitharu
d2ed8a1ace Update parsers 2024-12-07 15:18:18 +02:00
حيدر العراقي
024e3c11ee Translated using Weblate (Arabic)
Currently translated at 88.0% (668 of 759 strings)

Co-authored-by: حيدر العراقي <haiderdc12@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-12-07 15:17:34 +02:00
return_null
23ba302df8 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (759 of 759 strings)

Co-authored-by: return_null <demolang@dismail.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-12-07 15:17:34 +02:00
maryush
34e54e43e0 Translated using Weblate (Polish)
Currently translated at 100.0% (759 of 759 strings)

Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-12-07 15:17:34 +02:00
Anon
07a8de6225 Translated using Weblate (Serbian)
Currently translated at 99.6% (756 of 759 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-12-07 15:17:34 +02:00
gallegonovato
a3df6f799c Translated using Weblate (Spanish)
Currently translated at 100.0% (759 of 759 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-12-07 15:17:34 +02:00
Maple Javora
d5722790ef Translated using Weblate (Czech)
Currently translated at 89.0% (676 of 759 strings)

Co-authored-by: Maple Javora <jindrous101@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translation: Kotatsu/Strings
2024-12-07 15:17:34 +02:00
johan
8bf540abbe Translated using Weblate (Czech)
Currently translated at 89.0% (676 of 759 strings)

Co-authored-by: johan <jqb4@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translation: Kotatsu/Strings
2024-12-07 15:17:34 +02:00
gekka
5241fa0d13 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (759 of 759 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-12-07 15:17:34 +02:00
Dragibus Noir
87e0c931a2 Translated using Weblate (French)
Currently translated at 100.0% (759 of 759 strings)

Co-authored-by: Dragibus Noir <dragibusnoir@pm.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2024-12-07 15:17:34 +02:00
Infy's Tagalog Translations
a51412801a Translated using Weblate (Filipino)
Currently translated at 100.0% (759 of 759 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-12-07 15:17:34 +02:00
TheOneWhoCares
a6c188d647 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (759 of 759 strings)

Co-authored-by: TheOneWhoCares <266nre4gw@mozmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-12-07 15:17:34 +02:00
Frosted
831632cb8f Translated using Weblate (Turkish)
Currently translated at 100.0% (759 of 759 strings)

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-12-07 15:17:34 +02:00
Koitharu
ad59bf50f4 Fix loading local manga without index #1192 2024-12-05 18:39:31 +02:00
Koitharu
6fe6c05327 Update parsers 2024-12-05 18:38:47 +02:00
Koitharu
b5053b7820 Update parsers 2024-11-29 09:31:46 +02:00
Koitharu
e4df81495d Merge pull request #1184 from weblate/weblate-kotatsu-strings 2024-11-28 16:10:45 +02:00
Anon
295c5bed9f Translated using Weblate (Serbian)
Currently translated at 99.6% (755 of 758 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-11-28 12:24:33 +01:00
TheOneWhoCares
5fd1cbadcd Translated using Weblate (Portuguese (Brazil))
Currently translated at 97.4% (739 of 758 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 95.6% (725 of 758 strings)

Co-authored-by: TheOneWhoCares <266nre4gw@mozmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-11-28 12:24:33 +01:00
Gabriel Vasconcelos
9dd86f57e6 Translated using Weblate (Portuguese (Brazil))
Currently translated at 95.6% (725 of 758 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 93.6% (710 of 758 strings)

Co-authored-by: Gabriel Vasconcelos <gabriels.v9@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-11-28 12:24:33 +01:00
TheOneWhoCares
bce6d71743 Translated using Weblate (Portuguese (Brazil))
Currently translated at 93.6% (710 of 758 strings)

Co-authored-by: TheOneWhoCares <266nre4gw@mozmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-11-28 12:24:33 +01:00
Frosted
6367c06f49 Translated using Weblate (Turkish)
Currently translated at 100.0% (758 of 758 strings)

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-11-28 12:24:32 +01:00
Dragibus Noir
3aa8e9d6d3 Translated using Weblate (French)
Currently translated at 100.0% (758 of 758 strings)

Co-authored-by: Dragibus Noir <dragibusnoir@pm.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2024-11-28 12:24:32 +01:00
Draken
ac2b367312 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (758 of 758 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-11-28 12:24:32 +01:00
Justine Kyle Cobar
5cd9b02159 Translated using Weblate (Filipino)
Currently translated at 100.0% (758 of 758 strings)

Co-authored-by: Justine Kyle Cobar <cobarjustinekyle583@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-11-28 12:24:32 +01:00
Infy's Tagalog Translations
0bd62c6925 Translated using Weblate (Filipino)
Currently translated at 100.0% (758 of 758 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-11-28 12:24:32 +01:00
gekka
d657216a69 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (758 of 758 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (758 of 758 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-11-28 12:24:32 +01:00
大王叫我来巡山
39f91464dc Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (758 of 758 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-11-28 12:24:32 +01:00
gallegonovato
05422b95a1 Translated using Weblate (Spanish)
Currently translated at 100.0% (758 of 758 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-11-28 12:24:31 +01:00
arasseo.
554e3c1b61 Change ZERO_MS DNS
Switch to the primary DNS because the performance is better
2024-11-27 10:11:57 +02:00
Koitharu
56ece80f2a Bump version 2024-11-25 10:03:00 +02:00
Koitharu
3ebde0284d Kitsu fixes #1151 2024-11-24 13:42:52 +02:00
Koitharu
c993488fe7 Option to disable link handling #1149 2024-11-24 10:47:06 +02:00
Koitharu
e65a3b43f6 Fixes 2024-11-24 09:37:46 +02:00
Koitharu
f11a9d8235 Update parsers 2024-11-23 15:15:41 +02:00
Koitharu
8a4bd9a19a Fix "Deflater has been closed" error 2024-11-23 13:17:51 +02:00
Koitharu
cffc6cfd39 Fixes 2024-11-23 09:00:06 +02:00
Koitharu
1568a48328 Update parsers 2024-11-21 08:00:53 +02:00
nichind
0b47b113e0 Translated using Weblate (Russian)
Currently translated at 100.0% (755 of 755 strings)

Co-authored-by: nichind <nichinddev@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-11-21 07:56:13 +02:00
Koitharu
67a5ef016c Fix cleaning saved chapters 2024-11-18 18:59:17 +02:00
Anupam Malhotra
09c049ea9d Translated using Weblate (Hindi)
Currently translated at 88.7% (670 of 755 strings)

Co-authored-by: Anupam Malhotra <anpm.malhotra@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-11-16 17:25:21 +02:00
Gabriel Vasconcelos
0dc1cad52b Translated using Weblate (Portuguese (Brazil))
Currently translated at 93.5% (706 of 755 strings)

Co-authored-by: Gabriel Vasconcelos <gabriels.v9@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-11-16 17:25:21 +02:00
Milo Ivir
782ea0541e Translated using Weblate (Croatian)
Currently translated at 100.0% (755 of 755 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
2024-11-16 17:22:09 +02:00
Anon
b220703dd4 Translated using Weblate (Serbian)
Currently translated at 99.7% (753 of 755 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-11-16 17:22:09 +02:00
Paul Schönfisch
c5b6586cf4 Translated using Weblate (German)
Currently translated at 83.5% (631 of 755 strings)

Co-authored-by: Paul Schönfisch <asterdux2@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2024-11-16 17:22:09 +02:00
gallegonovato
1ba40ea248 Translated using Weblate (Spanish)
Currently translated at 100.0% (755 of 755 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-11-16 17:22:09 +02:00
Hosted Weblate
e8fd2b0dcf Update translation files
Updated by "Remove blank strings" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/
Translation: Kotatsu/Strings
2024-11-16 17:22:09 +02:00
Maple Javora
046b7b6ef1 Translated using Weblate (Czech)
Currently translated at 87.2% (657 of 753 strings)

Co-authored-by: Maple Javora <jindrous101@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translation: Kotatsu/Strings
2024-11-16 17:22:09 +02:00
Nicola Bortoletto
907856a0df Translated using Weblate (Italian)
Currently translated at 99.7% (753 of 755 strings)

Translated using Weblate (Italian)

Currently translated at 98.0% (738 of 753 strings)

Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2024-11-16 17:22:09 +02:00
gekka
071509ecd1 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (753 of 753 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-11-16 17:22:09 +02:00
Frosted
a0cb34b984 Translated using Weblate (Turkish)
Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (753 of 753 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (752 of 752 strings)

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-11-16 17:22:09 +02:00
Dragibus Noir
7fe8217f6d Translated using Weblate (French)
Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (French)

Currently translated at 100.0% (753 of 753 strings)

Translated using Weblate (French)

Currently translated at 100.0% (752 of 752 strings)

Co-authored-by: Dragibus Noir <dragibusnoir@pm.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2024-11-16 17:22:09 +02:00
Infy's Tagalog Translations
58937f9fc6 Translated using Weblate (Filipino)
Currently translated at 100.0% (752 of 752 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-11-16 17:22:09 +02:00
Илья
528b85e9ce Translated using Weblate (Russian)
Currently translated at 100.0% (752 of 752 strings)

Co-authored-by: Илья <ilya.megavolt.37@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-11-16 17:22:09 +02:00
Draken
b57fdd5a99 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (753 of 753 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (752 of 752 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (752 of 752 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-11-16 17:22:09 +02:00
大王叫我来巡山
1ad29cebd7 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (752 of 752 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-11-16 17:22:09 +02:00
gekka
7516303b7d Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (752 of 752 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (752 of 752 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-11-16 17:22:09 +02:00
Koitharu
b2bfebaea2 Update dependencies 2024-11-16 17:09:21 +02:00
Koitharu
9fcff1eac7 Fix crashes 2024-11-16 10:19:42 +02:00
Koitharu
19446db192 Misc improvements 2024-11-13 12:53:58 +02:00
Koitharu
609f2bd134 Fixes 2024-11-12 08:51:23 +02:00
Koitharu
644f0af262 Refactor dependencies catalog 2024-11-10 12:58:21 +02:00
Koitharu
a1e5d78877 Update parsers 2024-11-10 10:57:03 +02:00
Koitharu
635839065d Batch pages saving 2024-11-09 11:45:04 +02:00
Koitharu
bb6f7b1e9f Fix external backup crashes 2024-11-09 09:57:15 +02:00
Koitharu
1f0180d601 Fix periodical backup creation interval 2024-11-07 15:01:09 +02:00
Koitharu
cdce2af4a3 Fix nightly version name parsing 2024-11-07 14:47:52 +02:00
Koitharu
11212ed071 Update readme 2024-11-07 14:34:26 +02:00
Koitharu
e2902fa1ba Fix dependencies 2024-11-07 14:26:36 +02:00
Koitharu
5158f2a70a Merge branch 'CodeWithTamim-add/libs-version-toml-dependency-management' into devel 2024-11-07 13:11:18 +02:00
Koitharu
f9e4752b8c Merge branch 'add/libs-version-toml-dependency-management' of github.com:CodeWithTamim/Kotatsu into CodeWithTamim-add/libs-version-toml-dependency-management 2024-11-07 13:10:41 +02:00
Koitharu
901ffebf97 Change nightly updates repo 2024-11-06 09:54:53 +02:00
Koitharu
dba727bfcb Improvements for nightly build 2024-11-06 09:28:42 +02:00
Koitharu
3ee97a3b99 Fix nightly versionName/versionCode 2024-11-05 13:36:46 +02:00
Koitharu
57d1f54318 Refactor pages saving 2024-11-05 11:46:59 +02:00
Koitharu
02073f6d45 Convert launcher icons to webp 2024-11-05 09:22:30 +02:00
Koitharu
b66a77843e Add nightly build type 2024-11-05 09:21:24 +02:00
Koitharu
03518dd9b4 Update dependencies 2024-11-05 08:43:37 +02:00
Koitharu
d926f334e8 Merge branch 'master' into devel 2024-11-04 16:30:54 +02:00
Koitharu
e2f8d8e022 Fix downloading slowdown 2024-11-04 16:04:18 +02:00
Koitharu
38b342b721 Update dependencies, fix covers restoring 2024-11-04 15:41:16 +02:00
Koitharu
b036a8ed94 Fixes batch 2024-11-04 10:02:08 +02:00
Koitharu
e4fda86bf1 Small fixes 2024-11-02 15:35:01 +02:00
Koitharu
6e20cee972 Fix periodical backups 2024-11-02 15:34:55 +02:00
Claudio Riccio
8901d02dba Update app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt
Co-authored-by: Koitharu <nvasya95@gmail.com>
2024-11-02 13:10:41 +02:00
Claudio Riccio
a87b37ce1c Changes relative to issue#1102
Manga pages now have a proposed name as follow: "MangaName-MangaChapter-MangaPage_yyyy-MM-dd_HHmm.ImageExtension"
2024-11-02 13:10:41 +02:00
Claudio Riccio
4f22e29ad6 Changes relative to issue#1102
Manga pages now have a proposed name as follow: "MangaName-MangaChapter-MangaPage_yyyy-MM-dd_HHmm.ImageExtension"
2024-11-02 13:10:41 +02:00
Draken
6effb928fd Translated using Weblate (Vietnamese)
Currently translated at 100.0% (749 of 749 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-11-02 12:31:21 +02:00
Infy's Tagalog Translations
1b1d0014da Translated using Weblate (Filipino)
Currently translated at 100.0% (749 of 749 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-11-02 12:31:21 +02:00
gekka
a9632f542b Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (749 of 749 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-11-02 12:31:21 +02:00
gallegonovato
a2c256d47f Translated using Weblate (Spanish)
Currently translated at 100.0% (749 of 749 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-11-02 12:31:21 +02:00
Anonymous
f87a75e61e Translated using Weblate (Catalan)
Currently translated at 11.7% (88 of 749 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ca/
Translation: Kotatsu/Strings
2024-11-02 12:31:21 +02:00
Макар Разин
09354ae31f Translated using Weblate (Russian)
Currently translated at 100.0% (749 of 749 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (749 of 749 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (748 of 748 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-11-02 12:31:21 +02:00
Mahmuod abd alalem Selem abd al amed
fb25b8fb3a Translated using Weblate (Arabic)
Currently translated at 89.0% (666 of 748 strings)

Co-authored-by: Mahmuod abd alalem Selem abd al amed <selemabdalamedmahmuodabdalalem@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-11-02 12:31:21 +02:00
Justine Kyle Cobar
c8b935ccc3 Translated using Weblate (Filipino)
Currently translated at 100.0% (748 of 748 strings)

Co-authored-by: Justine Kyle Cobar <cobarjustinekyle583@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-11-02 12:31:21 +02:00
Anon
7f0376d792 Translated using Weblate (Serbian)
Currently translated at 100.0% (748 of 748 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-11-02 12:31:21 +02:00
Koitharu
0c56e730fe Change periodical backup creation 2024-11-02 12:30:04 +02:00
Koitharu
a7138d23ac Small fixes 2024-10-30 12:54:49 +02:00
Koitharu
a0de73a7ed PageSaveHelper refactor 2024-10-27 18:02:31 +02:00
Koitharu
90f0846fb4 Small fixes 2024-10-27 16:28:11 +02:00
Koitharu
9425d29596 Migrate LocalMangaInfo to Okio 2024-10-27 13:49:06 +02:00
Koitharu
9bb76cc0b2 Update parsers (fix json iterator) 2024-10-26 09:03:50 +03:00
Koitharu
ad0452486f Merge branch 'master' into devel 2024-10-24 12:48:06 +03:00
Koitharu
855b55da9d Update parsers 2024-10-23 19:47:27 +03:00
Koitharu
436168b940 Migrate to coil3 2024-10-23 18:55:10 +03:00
Koitharu
681c80dc3e Fix RegionBitmapDecode usage 2024-10-23 09:15:32 +03:00
Koitharu
4855b2c160 Fix RegionBitmapDecode usage 2024-10-23 09:10:43 +03:00
Koitharu
89d395178c Support for AVIF images
(cherry picked from commit c15a0ece3e)
2024-10-23 08:38:52 +03:00
Koitharu
9942ad5e56 Fix pages loading issues
(cherry picked from commit 5bccc595a8)
2024-10-23 08:37:50 +03:00
Koitharu
d59b0626bc Fix webtoon page detection #1140
(cherry picked from commit 985b062218)
2024-10-23 08:37:00 +03:00
Marius Albrecht
63054e55d6 Give "Complete" status only to fully completed Manga
Up until now a progress of >= 99.5% would count a Manga as completed (and show the checkmark icon). This causes manga with 200 chapters or more to be marked as completed even if they have at least one unread chapter.

https://github.com/KotatsuApp/Kotatsu/issues/1105
(cherry picked from commit b6f57e5656)
2024-10-23 08:36:52 +03:00
Koitharu
486daf69bf Update link resolver
(cherry picked from commit c1d577bdf3)
2024-10-23 08:36:38 +03:00
Koitharu
af209d7048 Fix external plugin communication
(cherry picked from commit 2214c20742)
2024-10-23 08:36:26 +03:00
Koitharu
c15a0ece3e Support for AVIF images 2024-10-22 14:20:13 +03:00
Tamim Hossain
6bf034fd37 Add libs.versions.toml for centralized dependency management
- Introduced `libs.versions.toml` to manage dependencies in a centralized and structured manner.
- This improves maintainability and makes it easier to update and manage library versions across the project.
- Follows best practices for Gradle dependency management by separating version definitions from build scripts.
2024-10-22 01:46:26 +06:00
Koitharu
5bccc595a8 Fix pages loading issues 2024-10-21 13:34:33 +03:00
Priit Jõerüüt
9559e148c6 Translated using Weblate (Estonian)
Currently translated at 70.3% (526 of 748 strings)

Co-authored-by: Priit Jõerüüt <hwlate@joeruut.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/et/
Translation: Kotatsu/Strings
2024-10-21 10:17:44 +03:00
J. Lavoie
637a040a0b Translated using Weblate (French)
Currently translated at 99.1% (742 of 748 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2024-10-21 10:17:44 +03:00
Yoshi Nizar
2bdf146548 Translated using Weblate (Italian)
Currently translated at 90.9% (680 of 748 strings)

Co-authored-by: Yoshi Nizar <canalefinto@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2024-10-21 10:17:44 +03:00
shimanchu
22831a9796 Translated using Weblate (Japanese)
Currently translated at 62.1% (464 of 747 strings)

Co-authored-by: shimanchu <shimano@knd.biglobe.ne.jp>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2024-10-21 10:17:44 +03:00
大王叫我来巡山
b5bc64c89f Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (747 of 747 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-10-21 10:17:44 +03:00
Hosted Weblate
f2ad58bc97 Update translation files
Updated by "Remove blank strings" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/
Translation: Kotatsu/Strings
2024-10-21 10:17:44 +03:00
Akhil Raj
835a1c73b6 Translated using Weblate (Malayalam)
Currently translated at 2.8% (21 of 744 strings)

Co-authored-by: Akhil Raj <89210430+akhi07rx@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ml/
Translation: Kotatsu/Strings
2024-10-21 10:17:44 +03:00
Felipe Nascimento
5b8a628715 Translated using Weblate (Portuguese)
Currently translated at 98.7% (735 of 744 strings)

Co-authored-by: Felipe Nascimento <f.kgb@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-10-21 10:17:44 +03:00
Nayuki
4f5418e074 Translated using Weblate (Thai)
Currently translated at 61.0% (454 of 744 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2024-10-21 10:17:44 +03:00
Infy's Tagalog Translations
1cf56b2303 Translated using Weblate (Filipino)
Currently translated at 98.2% (734 of 747 strings)

Translated using Weblate (Filipino)

Currently translated at 98.2% (731 of 744 strings)

Translated using Weblate (Filipino)

Currently translated at 98.2% (731 of 744 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-10-21 10:17:44 +03:00
maryush
a47dcd9ec2 Translated using Weblate (Polish)
Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (737 of 738 strings)

Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-10-21 10:17:44 +03:00
Макар Разин
7873cc4099 Translated using Weblate (Russian)
Currently translated at 100.0% (733 of 733 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (733 of 733 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-10-21 10:17:44 +03:00
gekka
9002915e30 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (738 of 738 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (733 of 733 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-10-21 10:17:44 +03:00
Draken
099d9df84c Translated using Weblate (Vietnamese)
Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (738 of 738 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (733 of 733 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-10-21 10:17:44 +03:00
大王叫我来巡山
e531e6bcb8 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (733 of 733 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-10-21 10:17:44 +03:00
Oğuz Ersen
77ed44bb08 Translated using Weblate (Turkish)
Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (738 of 738 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (733 of 733 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-10-21 10:17:44 +03:00
gallegonovato
1b0b495029 Translated using Weblate (Spanish)
Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (738 of 738 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (733 of 733 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-10-21 10:17:44 +03:00
Anon
b6296fd586 Translated using Weblate (Serbian)
Currently translated at 100.0% (738 of 738 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (732 of 732 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-10-21 10:17:44 +03:00
Koitharu
985b062218 Fix webtoon page detection #1140 2024-10-21 10:16:54 +03:00
Marius Albrecht
b6f57e5656 Give "Complete" status only to fully completed Manga
Up until now a progress of >= 99.5% would count a Manga as completed (and show the checkmark icon). This causes manga with 200 chapters or more to be marked as completed even if they have at least one unread chapter.

https://github.com/KotatsuApp/Kotatsu/issues/1105
2024-10-21 10:13:14 +03:00
Koitharu
3d285104a4 Search through settings 2024-10-20 17:01:17 +03:00
Koitharu
100073f45e Reader screen orientation settings 2024-10-16 18:16:14 +03:00
Koitharu
c1d577bdf3 Update link resolver 2024-10-16 13:19:34 +03:00
Koitharu
2214c20742 Fix external plugin communication 2024-10-13 18:24:48 +03:00
Koitharu
688a9fe4d5 Option to open manga source in browser 2024-10-13 18:00:05 +03:00
Koitharu
af5df32fbe Merge branch 'master' into devel 2024-10-13 17:22:25 +03:00
Koitharu
d739e30c84 Improve filter 2024-10-13 16:05:52 +03:00
Koitharu
32eb273fa9 Update parsers 2024-10-13 15:47:43 +03:00
Koitharu
8c5231bb3d Fix read chapters deletion 2024-10-13 14:09:03 +03:00
Koitharu
be4fb3e873 Fix saving cover 2024-10-13 14:09:03 +03:00
Koitharu
d28eff7a75 Fix zip closing 2024-10-13 14:09:03 +03:00
Koitharu
b81063910b Fix read chapters deletion 2024-10-13 14:08:25 +03:00
Koitharu
702ee70f70 Fix saving cover 2024-10-13 09:43:01 +03:00
Koitharu
c5bd979645 Fix zip closing 2024-10-13 09:39:28 +03:00
Koitharu
e515069b53 Fix zip closing
(cherry picked from commit 144e66bedb)
2024-10-11 17:16:57 +03:00
Koitharu
3255fba3c4 Ask for download via metered network 2024-10-11 17:16:31 +03:00
Koitharu
144e66bedb Fix zip closing 2024-10-11 10:55:47 +03:00
Koitharu
05d22167c4 Fix skipping download errors 2024-10-11 10:15:29 +03:00
Koitharu
e5c765dd2f Update parsers 2024-10-11 09:57:58 +03:00
Koitharu
557b69d73f New download dialog 2024-10-10 16:30:01 +03:00
Koitharu
1e22e8de45 Improve filter 2024-10-07 20:02:34 +03:00
Koitharu
9ea1122ca0 Fix CloudFlare protection detection (close #1129) 2024-10-07 15:24:02 +03:00
Koitharu
4faef85086 Fix sources suggestion 2024-10-07 14:40:29 +03:00
Koitharu
b46c00f2d0 Fix parsers version 2024-10-05 16:28:29 +03:00
Anon
9358617a3a Translated using Weblate (Serbian)
Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Serbian)

Currently translated at 96.4% (703 of 729 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-10-05 16:03:55 +03:00
Draken
ba9f31835f Translated using Weblate (Vietnamese)
Currently translated at 100.0% (732 of 732 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (729 of 729 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-10-05 16:03:55 +03:00
abc0922001
357308bfbb Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 90.5% (660 of 729 strings)

Co-authored-by: abc0922001 <abc0922001@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2024-10-05 16:03:55 +03:00
gekka
cab56209c1 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (732 of 732 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (729 of 729 strings)

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

Translated using Weblate (Turkish)

Currently translated at 100.0% (729 of 729 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-10-05 16:03:55 +03:00
gallegonovato
357517ceac Translated using Weblate (Spanish)
Currently translated at 100.0% (732 of 732 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (729 of 729 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-10-05 16:03:55 +03:00
Infy's Tagalog Translations
a57fcce72b Translated using Weblate (Filipino)
Currently translated at 98.3% (717 of 729 strings)

Translated using Weblate (Filipino)

Currently translated at 98.3% (716 of 728 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-10-05 16:03:55 +03:00
Priit Jõerüüt
2e2a818c05 Translated using Weblate (Estonian)
Currently translated at 71.0% (520 of 732 strings)

Translated using Weblate (Estonian)

Currently translated at 67.9% (495 of 728 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Priit Jõerüüt <hwlate@joeruut.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/et/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/et/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-10-05 16:03:55 +03:00
Matt
b6f618101f Translated using Weblate (Portuguese)
Currently translated at 98.7% (719 of 728 strings)

Co-authored-by: Matt <contact.mattdev@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-10-05 16:03:55 +03:00
Koitharu
0ce368751a Merge branch 'devel' of github.com:KotatsuApp/Kotatsu into devel 2024-10-05 16:03:07 +03:00
Koitharu
1d28538893 Fixes batch 2024-10-05 16:02:49 +03:00
Koitharu
4ad2f3f608 Fixes batch 2024-10-04 14:27:01 +03:00
Koitharu
5301cc7f97 Ability to start download paused 2024-10-04 11:20:49 +03:00
Koitharu
1290db4a7c Fixes batch 2024-10-04 10:23:49 +03:00
Koitharu
1f1309d934 Increase source add button size 2024-10-03 14:32:34 +03:00
Koitharu
350f1521a6 Fix warnings and cleanup 2024-10-03 13:08:09 +03:00
mnv
cebce20bed Fix MangaSource import and improve user agent handling
MangaSource class was imported twice from different packages.
2024-10-03 09:16:30 +03:00
Koitharu
e5b6947586 Fix StrictMode errors 2024-10-02 16:39:21 +03:00
Koitharu
ac96c49b60 Strict mode notificaiton for debug build 2024-10-02 15:13:54 +03:00
Koitharu
a4345a40bf Update dependencies 2024-10-02 11:34:51 +03:00
Koitharu
f518acb8ee Skip error for local manga list (close #1113, close #1115) 2024-09-29 19:46:48 +03:00
大王叫我来巡山
b39a51d497 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (728 of 728 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-09-29 19:43:34 +03:00
Felipe Nascimento
8819d8b1ee Translated using Weblate (Portuguese)
Currently translated at 98.6% (718 of 728 strings)

Co-authored-by: Felipe Nascimento <f.kgb@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-09-29 19:43:34 +03:00
Draken
05a502b89a Translated using Weblate (Vietnamese)
Currently translated at 100.0% (728 of 728 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (724 of 724 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-09-29 19:43:34 +03:00
gekka
c320e3c26a Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.8% (723 of 724 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-09-29 19:43:34 +03:00
Matt
938849c31e Translated using Weblate (Japanese)
Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Matt <contact.mattdev@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ja/
Translation: Kotatsu/plurals
2024-09-29 19:43:34 +03:00
Oğuz Ersen
95c243daa1 Translated using Weblate (Turkish)
Currently translated at 100.0% (728 of 728 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (724 of 724 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-09-29 19:43:34 +03:00
gallegonovato
6ce6a02b56 Translated using Weblate (Spanish)
Currently translated at 100.0% (728 of 728 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (724 of 724 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-09-29 19:43:34 +03:00
Koitharu
e92e9fb393 Update SSIV 2024-09-29 19:43:09 +03:00
Koitharu
f4186a2787 Remove loggers and reorganize settings 2024-09-27 14:40:31 +03:00
Koitharu
8b93b699d3 Update readme 2024-09-26 16:02:52 +03:00
Koitharu
7e13482ba5 Translated using Weblate (Russian)
Currently translated at 99.7% (722 of 724 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-09-26 15:34:44 +03:00
gallegonovato
04700a22c8 Translated using Weblate (Spanish)
Currently translated at 100.0% (724 of 724 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-09-26 15:34:44 +03:00
Hosted Weblate
549d08cc06 Update translation files
Updated by "Remove blank strings" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/
Translation: Kotatsu/Strings
2024-09-26 15:34:44 +03:00
Infy's Tagalog Translations
0fccaf3fbc Translated using Weblate (Filipino)
Currently translated at 98.4% (713 of 724 strings)

Translated using Weblate (Filipino)

Currently translated at 98.8% (712 of 720 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-09-26 15:34:44 +03:00
Felipe Nascimento
c7e0a47bee Translated using Weblate (Portuguese)
Currently translated at 98.7% (711 of 720 strings)

Co-authored-by: Felipe Nascimento <f.kgb@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-09-26 15:34:44 +03:00
Макар Разин
d527b6e390 Translated using Weblate (Russian)
Currently translated at 100.0% (720 of 720 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (720 of 720 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-09-26 15:34:44 +03:00
Koitharu
12b2af6b93 Update parsers 2024-09-26 14:46:36 +03:00
Koitharu
63f4fab40f Fix applying filter 2024-09-26 12:33:35 +03:00
Koitharu
9a444cf965 Migrate external sources to new filter 2024-09-25 12:28:10 +03:00
Koitharu
b8be2f7158 Fix sync auth activity ui 2024-09-25 09:48:17 +03:00
Draken
9e2074040f Translated using Weblate (Vietnamese)
Currently translated at 100.0% (720 of 720 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-09-25 09:47:57 +03:00
Infy's Tagalog Translations
020c151e31 Translated using Weblate (Filipino)
Currently translated at 98.8% (712 of 720 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-09-25 09:47:57 +03:00
gekka
52eb33a992 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.8% (719 of 720 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-09-25 09:47:57 +03:00
大王叫我来巡山
907b8fd0ec Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.8% (719 of 720 strings)

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

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-09-25 09:47:57 +03:00
Faiz Faadhillah
fbb4efb3df Improve Spen integration support 2024-09-25 09:47:03 +03:00
Koitharu
7ff47a322e Update parsers 2024-09-24 17:56:11 +03:00
Koitharu
fda1af5500 Merge branch 'devel' of https://hosted.weblate.org/git/kotatsu/strings into devel 2024-09-24 17:51:14 +03:00
Koitharu
d88847d137 Various fixes 2024-09-24 17:48:13 +03:00
Koitharu
063527b240 Context menus 2024-09-24 17:48:12 +03:00
gallegonovato
b0470110a8 Translated using Weblate (Spanish)
Currently translated at 100.0% (720 of 720 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
2024-09-24 16:46:39 +02:00
Anonymous
5a2a31d1c8 Translated using Weblate (Hungarian)
Currently translated at 86.6% (622 of 718 strings)

Translated using Weblate (Nepali)

Currently translated at 32.3% (232 of 718 strings)

Translated using Weblate (Hindi)

Currently translated at 93.0% (668 of 718 strings)

Translated using Weblate (Portuguese)

Currently translated at 92.7% (666 of 718 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hu/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ne/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
desu sude
3b009d7c55 Translated using Weblate (Latvian)
Currently translated at 24.4% (175 of 717 strings)

Co-authored-by: desu sude <cobsonslittlecocksleeve@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/lv/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
Anon
f7e937f2b8 Translated using Weblate (Serbian)
Currently translated at 100.0% (698 of 698 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
Milo Ivir
16e23cc1cf Translated using Weblate (Croatian)
Currently translated at 98.7% (689 of 698 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
Infy's Tagalog Translations
d12528d80f Translated using Weblate (Filipino)
Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (698 of 698 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
abc0922001
9f04c7b148 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 92.1% (643 of 698 strings)

Co-authored-by: abc0922001 <abc0922001@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
Amirreza Safavi
7a3942f100 Translated using Weblate (Persian)
Currently translated at 41.6% (288 of 692 strings)

Co-authored-by: Amirreza Safavi <amirxcatsanddragons@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
gekka
8e46f64f2a Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.7% (716 of 718 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.7% (696 of 698 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.7% (696 of 698 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.8% (691 of 692 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
Shayan
44c50fca2d Translated using Weblate (Persian)
Currently translated at 41.6% (288 of 692 strings)

Co-authored-by: Shayan <shayans31516@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
Amirreza Safavi
55b4d14a93 Translated using Weblate (Persian)
Currently translated at 41.6% (288 of 692 strings)

Co-authored-by: Amirreza Safavi <amirxcatsanddragons@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
gallegonovato
743693299f Translated using Weblate (Spanish)
Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (717 of 717 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (692 of 692 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
Draken
7950a685a6 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (717 of 717 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (692 of 692 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
Oğuz Ersen
97cfcb5c01 Translated using Weblate (Turkish)
Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (717 of 717 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (710 of 710 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (692 of 692 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
Fikri Akbar
b2dfcefee8 Translated using Weblate (Indonesian)
Currently translated at 99.8% (688 of 689 strings)

Co-authored-by: Fikri Akbar <akbarfikri1221@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
Anonymous
ee1ade40c3 Translated using Weblate (Hungarian)
Currently translated at 86.6% (622 of 718 strings)

Translated using Weblate (Nepali)

Currently translated at 32.3% (232 of 718 strings)

Translated using Weblate (Hindi)

Currently translated at 93.0% (668 of 718 strings)

Translated using Weblate (Portuguese)

Currently translated at 92.7% (666 of 718 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hu/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ne/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-09-24 11:03:58 +02:00
desu sude
3690e15cff Translated using Weblate (Latvian)
Currently translated at 24.4% (175 of 717 strings)

Co-authored-by: desu sude <cobsonslittlecocksleeve@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/lv/
Translation: Kotatsu/Strings
2024-09-24 11:03:58 +02:00
Anon
a955dfbe50 Translated using Weblate (Serbian)
Currently translated at 100.0% (698 of 698 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-09-24 11:03:58 +02:00
Milo Ivir
5e9daa1206 Translated using Weblate (Croatian)
Currently translated at 98.7% (689 of 698 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
2024-09-24 11:03:58 +02:00
Infy's Tagalog Translations
a3c2956a4d Translated using Weblate (Filipino)
Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (698 of 698 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-09-24 11:03:58 +02:00
abc0922001
10ecd92715 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 92.1% (643 of 698 strings)

Co-authored-by: abc0922001 <abc0922001@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2024-09-24 11:03:58 +02:00
Amirreza Safavi
37d2d986ef Translated using Weblate (Persian)
Currently translated at 41.6% (288 of 692 strings)

Co-authored-by: Amirreza Safavi <amirxcatsanddragons@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
2024-09-24 11:03:58 +02:00
gekka
0aadd6ebe2 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.8% (717 of 718 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.7% (716 of 718 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.7% (696 of 698 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.7% (696 of 698 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.8% (691 of 692 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-09-24 11:03:58 +02:00
Shayan
c23ec9a4b8 Translated using Weblate (Persian)
Currently translated at 41.6% (288 of 692 strings)

Co-authored-by: Shayan <shayans31516@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
2024-09-24 11:03:58 +02:00
Amirreza Safavi
22a37923f9 Translated using Weblate (Persian)
Currently translated at 41.6% (288 of 692 strings)

Co-authored-by: Amirreza Safavi <amirxcatsanddragons@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
2024-09-24 11:03:57 +02:00
gallegonovato
3fc506b438 Translated using Weblate (Spanish)
Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (717 of 717 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (692 of 692 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-09-24 11:03:57 +02:00
Draken
e98dbd5069 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (717 of 717 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (692 of 692 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-09-24 11:03:57 +02:00
Oğuz Ersen
2a469b27c5 Translated using Weblate (Turkish)
Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (717 of 717 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (710 of 710 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (692 of 692 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-09-24 11:03:57 +02:00
Fikri Akbar
0f3ef4559f Translated using Weblate (Indonesian)
Currently translated at 99.8% (688 of 689 strings)

Co-authored-by: Fikri Akbar <akbarfikri1221@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-09-24 11:03:57 +02:00
Koitharu
a87ef0a0a6 Search in history, favorites and local 2024-09-24 11:44:02 +03:00
Koitharu
a7a0a7f0db Fix sources catalog content types 2024-09-24 10:18:10 +03:00
Koitharu
bc4622d610 Local manga source filter 2024-09-23 19:22:48 +03:00
Koitharu
8365603bf1 Update supported domains 2024-09-23 18:28:55 +03:00
Koitharu
b1eabdba79 Improve quick filters 2024-09-23 14:46:29 +03:00
Koitharu
169e31e9ba Local manga index in database 2024-09-23 08:55:17 +03:00
Koitharu
66644d55a4 Search manga with filters 2024-09-22 17:44:36 +03:00
Koitharu
98314960cf Improve filter 2024-09-21 11:58:45 +03:00
Koitharu
b73e44874d Add new filter fields 2024-09-21 09:11:58 +03:00
Koitharu
6f45a44070 Update parsers and filters 2024-09-21 08:22:32 +03:00
Koitharu
d9d11d685e Update parsers 2024-09-15 16:05:52 +03:00
Koitharu
5359267b5a Update dependencies 2024-09-15 15:16:07 +03:00
Koitharu
a6662ab501 Batch manga fix functionality 2024-09-15 15:02:34 +03:00
Amirreza Safavi
699a619c27 Translated using Weblate (Persian)
Currently translated at 77.7% (7 of 9 strings)

Translated using Weblate (Persian)

Currently translated at 37.4% (258 of 689 strings)

Co-authored-by: Amirreza Safavi <amirxcatsanddragons@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fa/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-09-14 14:42:29 +03:00
Anon
85ccbbf719 Translated using Weblate (Serbian)
Currently translated at 100.0% (689 of 689 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-09-14 14:42:29 +03:00
Henrique
a396b33f3d Translated using Weblate (Portuguese (Brazil))
Currently translated at 96.6% (666 of 689 strings)

Co-authored-by: Henrique <heluis110@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-09-14 14:42:29 +03:00
Макар Разин
6076f775c3 Translated using Weblate (Russian)
Currently translated at 100.0% (689 of 689 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (689 of 689 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-09-14 14:42:29 +03:00
Dilip Patel
379fa88b4e grammar fix 2024-09-14 14:39:36 +03:00
Koitharu
9b24c507c5 Option to automatically download new chapters (close #425, close #602, close #955) 2024-09-14 12:12:55 +03:00
Koitharu
98bd79f0be Update dependencies 2024-09-11 08:50:51 +03:00
Koitharu
f09e28e782 Fix applying global color filter (close #1088) 2024-09-06 12:19:59 +03:00
Koitharu
b601b07586 Optimize the Downloaded quick filter 2024-09-06 11:35:23 +03:00
Koitharu
73cea59691 Change pref key for ProxyType 2024-09-06 07:45:48 +03:00
Koitharu
e2993d47b6 Fix closing ZipFile 2024-09-06 07:44:29 +03:00
Koitharu
2cd67e7cf8 Fix crashes 2024-09-06 07:28:17 +03:00
Koitharu
c51da5a9d5 Update parsers 2024-09-05 12:52:03 +03:00
Justine Kyle Cobar
bcfce29610 Translated using Weblate (Filipino)
Currently translated at 100.0% (689 of 689 strings)

Co-authored-by: Justine Kyle Cobar <cobarjustinekyle583@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-09-05 11:03:14 +03:00
maryush
a87d18fae3 Translated using Weblate (Polish)
Currently translated at 99.8% (688 of 689 strings)

Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-09-05 11:03:14 +03:00
gekka
bbd421445c Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (689 of 689 strings)

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

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-09-05 11:03:14 +03:00
Denis Bolba
bd65cbb8b8 Translated using Weblate (Romanian)
Currently translated at 12.9% (89 of 689 strings)

Added translation using Weblate (Romanian)

Added translation using Weblate (Romanian)

Co-authored-by: Denis Bolba <bolbadenis4@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ro/
Translation: Kotatsu/Strings
2024-09-05 11:03:14 +03:00
Draken
7d1f81607a Translated using Weblate (Vietnamese)
Currently translated at 100.0% (689 of 689 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-09-05 11:03:14 +03:00
gallegonovato
3b6cd0ea7f Translated using Weblate (Spanish)
Currently translated at 100.0% (689 of 689 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-09-05 11:03:14 +03:00
Koitharu
aff70d8519 Update dependencies 2024-09-05 10:30:52 +03:00
Koitharu
8a74faa4f0 Fix Downloaded quick filter (close #1076, close #1079) 2024-09-05 10:16:09 +03:00
Koitharu
c1ac207809 Fix downloading (close #1072) 2024-09-04 14:33:08 +03:00
Koitharu
e34e745c84 Fix reading saved manga offline (close #1081, close #1071) 2024-09-04 13:41:57 +03:00
Koitharu
50dd119ab5 Fixes 2024-09-03 17:47:36 +03:00
Koitharu
d0ef177d56 Fix sort order direction in filter 2024-09-03 14:52:04 +03:00
600 changed files with 12992 additions and 7251 deletions

1
.gitignore vendored
View File

@@ -26,3 +26,4 @@
.cxx
/.idea/deviceManager.xml
/.kotlin/
/.idea/AndroidProjectSystem.xml

View File

@@ -1,27 +1,27 @@
# Kotatsu
Kotatsu is a free and open source manga reader for Android.
Kotatsu is a free and open-source manga reader for Android with built-in online content sources.
![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/KotatsuApp/Kotatsu) ![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF)](https://t.me/kotatsuapp) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5)
[![Sources count](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FKotatsuApp%2Fkotatsu-parsers%2Frefs%2Fheads%2Fmaster%2F.github%2Fsummary.yaml&query=total&label=manga%20sources&color=%23E9321C)](https://github.com/KotatsuApp/kotatsu-parsers) ![F-Droid Version](https://img.shields.io/f-droid/v/org.koitharu.kotatsu) ![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF)](https://t.me/kotatsuapp) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) [![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu)](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
### Download
- **Recommended:** Download and install APK from **[GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest)**. Application has a built-in self-updating feature.
- Get it on **[F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu)**. The F-Droid build may be a bit outdated and some fixes might be missing.
- Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (very unstable, use at your own risk).
### Main Features
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
* Search manga by name and genres
* Search manga by name, genres, and more filters
* Reading history and bookmarks
* Favourites organized by user-defined categories
* Favorites organized by user-defined categories
* Downloading manga and reading it offline. Third-party CBZ archives also supported
* Tablet-optimized Material You UI
* Standard and Webtoon-optimized reader
* Standard and Webtoon-optimized customizable reader
* Notifications about new chapters with updates feed
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
* Password/fingerprint protect access to the app
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices
* Password/fingerprint-protected access to the app
### Screenshots
@@ -53,5 +53,5 @@ install instructions.
### DMCA disclaimer
The developers of this application does not have any affiliation with the content available in the app.
It is collecting from the sources freely available through any web browser.
The developers of this application do not have any affiliation with the content available in the app.
It collects content from sources that are freely available through any web browser

View File

@@ -1,3 +1,5 @@
import java.time.LocalDateTime
plugins {
id 'com.android.application'
id 'kotlin-android'
@@ -16,8 +18,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 35
versionCode = 666
versionName = '7.5'
versionCode = 700
versionName = '7.7.8'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
@@ -37,34 +39,46 @@ android {
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
nightly {
initWith release
applicationIdSuffix = '.nightly'
}
}
buildFeatures {
viewBinding true
buildConfig true
}
packagingOptions {
resources {
excludes += [
'META-INF/README.md',
'META-INF/NOTICE.md'
]
}
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
main.java.srcDirs += 'src/main/kotlin/'
}
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
jvmTarget = JavaVersion.VERSION_11.toString()
freeCompilerArgs += [
'-opt-in=kotlin.ExperimentalStdlibApi',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
'-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=coil.annotation.ExperimentalCoilApi',
'-opt-in=coil3.annotation.ExperimentalCoilApi',
]
}
lint {
abortOnError true
disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged', 'SetJavaScriptEnabled'
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat'
}
testOptions {
unitTests.includeAndroidResources true
@@ -73,6 +87,15 @@ android {
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
}
}
applicationVariants.configureEach { variant ->
if (variant.name == 'nightly') {
variant.outputs.each { output ->
def now = LocalDateTime.now()
output.versionCodeOverride = now.format("yyMMdd").toInteger()
output.versionNameOverride = 'N' + now.format("yyyyMMdd")
}
}
}
}
afterEvaluate {
compileDebugKotlin {
@@ -82,87 +105,92 @@ afterEvaluate {
}
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:b404b44008') {
def parsersVersion = libs.versions.parsers.get()
if (System.properties.containsKey('parsersVersionOverride')) {
// usage:
// -DparsersVersionOverride=$(curl -s https://api.github.com/repos/kotatsuapp/kotatsu-parsers/commits/master -H "Accept: application/vnd.github.sha" | cut -c -10)
parsersVersion = System.getProperty('parsersVersionOverride')
}
//noinspection UseTomlInstead
implementation("com.github.KotatsuApp:kotatsu-parsers:$parsersVersion") {
exclude group: 'org.json', module: 'json'
}
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.1'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.10'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0-RC.2'
coreLibraryDesugaring libs.desugar.jdk.libs
implementation libs.kotlin.stdlib
implementation libs.kotlinx.coroutines.android
implementation libs.kotlinx.coroutines.guava
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.activity:activity-ktx:1.9.1'
implementation 'androidx.fragment:fragment-ktx:1.8.2'
implementation 'androidx.transition:transition-ktx:1.5.1'
implementation 'androidx.collection:collection-ktx:1.4.3'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4'
implementation 'androidx.lifecycle:lifecycle-service:2.8.4'
implementation 'androidx.lifecycle:lifecycle-process:2.8.4'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.viewpager2:viewpager2:1.1.0'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.4'
implementation 'androidx.webkit:webkit:1.11.0'
implementation libs.androidx.appcompat
implementation libs.androidx.core
implementation libs.androidx.activity
implementation libs.androidx.fragment
implementation libs.androidx.transition
implementation libs.androidx.collection
implementation libs.lifecycle.viewmodel
implementation libs.lifecycle.service
implementation libs.lifecycle.process
implementation libs.androidx.constraintlayout
implementation libs.androidx.swiperefreshlayout
implementation libs.androidx.recyclerview
implementation libs.androidx.viewpager2
implementation libs.androidx.preference
implementation libs.androidx.biometric
implementation libs.material
implementation libs.androidx.lifecycle.common.java8
implementation libs.androidx.webkit
implementation 'androidx.work:work-runtime:2.9.1'
//noinspection GradleDependency
implementation('com.google.guava:guava:33.2.1-android') {
exclude group: 'com.google.guava', module: 'failureaccess'
exclude group: 'org.checkerframework', module: 'checker-qual'
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
}
implementation libs.androidx.work.runtime
implementation libs.guava
implementation 'androidx.room:room-runtime:2.6.1'
implementation 'androidx.room:room-ktx:2.6.1'
ksp 'androidx.room:room-compiler:2.6.1'
implementation libs.androidx.room.runtime
implementation libs.androidx.room.ktx
ksp libs.androidx.room.compiler
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okhttp3:okhttp-tls:4.12.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
implementation 'com.squareup.okio:okio:3.9.0'
implementation libs.okhttp
implementation libs.okhttp.tls
implementation libs.okhttp.dnsoverhttps
implementation libs.okio
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation libs.adapterdelegates
implementation libs.adapterdelegates.viewbinding
implementation 'com.google.dagger:hilt-android:2.52'
kapt 'com.google.dagger:hilt-compiler:2.52'
implementation 'androidx.hilt:hilt-work:1.2.0'
kapt 'androidx.hilt:hilt-compiler:1.2.0'
implementation libs.hilt.android
kapt libs.hilt.compiler
implementation libs.androidx.hilt.work
kapt libs.androidx.hilt.compiler
implementation 'io.coil-kt:coil-base:2.7.0'
implementation 'io.coil-kt:coil-svg:2.7.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:4ec7176962'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'
implementation libs.coil.core
implementation libs.coil.network
implementation libs.coil.gif
implementation libs.coil.svg
implementation libs.avif.decoder
implementation libs.ssiv
implementation libs.disk.lru.cache
implementation libs.markwon
implementation 'ch.acra:acra-http:5.11.3'
implementation 'ch.acra:acra-dialog:5.11.3'
implementation libs.acra.http
implementation libs.acra.dialog
implementation 'org.conscrypt:conscrypt-android:2.5.3'
implementation libs.conscrypt.android
debugImplementation 'com.squareup.leakcanary:leakcanary-android:3.0-alpha-8'
debugImplementation 'com.github.Koitharu:WorkInspector:5778dd1747'
debugImplementation libs.leakcanary.android
debugImplementation libs.workinspector
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20240303'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
testImplementation libs.junit
testImplementation libs.json
testImplementation libs.kotlinx.coroutines.test
androidTestImplementation 'androidx.test:runner:1.6.1'
androidTestImplementation 'androidx.test:rules:1.6.1'
androidTestImplementation 'androidx.test:core-ktx:1.6.1'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
androidTestImplementation libs.androidx.runner
androidTestImplementation libs.androidx.rules
androidTestImplementation libs.androidx.test.core
androidTestImplementation libs.androidx.junit
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
androidTestImplementation libs.kotlinx.coroutines.test
androidTestImplementation 'androidx.room:room-testing:2.6.1'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
androidTestImplementation libs.androidx.room.testing
androidTestImplementation libs.moshi.kotlin
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.52'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.52'
androidTestImplementation libs.hilt.android.testing
kaptAndroidTest libs.hilt.android.compiler
}

View File

@@ -15,6 +15,7 @@
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
-dontwarn com.google.j2objc.annotations.**
-dontwarn coil3.PlatformContext
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
@@ -22,3 +23,8 @@
-keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }
-keep class org.jsoup.parser.Tag
-keep class org.jsoup.internal.StringUtil
-keep class org.acra.security.NoKeyStoreFactory { *; }
-keep class org.acra.config.DefaultRetryPolicy { *; }
-keep class org.acra.attachment.DefaultAttachmentProvider { *; }
-keep class org.acra.sender.JobSenderService

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu
import android.content.Context
import android.os.Build
import android.os.StrictMode
import androidx.fragment.app.strictmode.FragmentStrictMode
import org.koitharu.kotatsu.core.BaseApp
@@ -18,30 +19,55 @@ class KotatsuApp : BaseApp() {
}
private fun enableStrictMode() {
val notifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
StrictModeNotifier(this)
} else {
null
}
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.build(),
StrictMode.ThreadPolicy.Builder().apply {
detectNetwork()
detectDiskWrites()
detectCustomSlowCalls()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) detectResourceMismatches()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) detectExplicitGc()
penaltyLog()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
penaltyListener(notifier.executor, notifier)
}
}.build(),
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectAll()
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
.setClassInstanceLimit(PagesCache::class.java, 1)
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
.setClassInstanceLimit(PageLoader::class.java, 1)
.setClassInstanceLimit(ReaderViewModel::class.java, 1)
.penaltyLog()
.build(),
StrictMode.VmPolicy.Builder().apply {
detectActivityLeaks()
detectLeakedSqlLiteObjects()
detectLeakedClosableObjects()
detectLeakedRegistrationObjects()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectContentUriWithoutPermission()
detectFileUriExposure()
setClassInstanceLimit(LocalMangaRepository::class.java, 1)
setClassInstanceLimit(PagesCache::class.java, 1)
setClassInstanceLimit(MangaLoaderContext::class.java, 1)
setClassInstanceLimit(PageLoader::class.java, 1)
setClassInstanceLimit(ReaderViewModel::class.java, 1)
penaltyLog()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
penaltyListener(notifier.executor, notifier)
}
}.build()
)
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
.penaltyDeath()
.detectFragmentReuse()
.detectWrongFragmentContainer()
.detectRetainInstanceUsage()
.detectSetUserVisibleHint()
.detectFragmentTagUsage()
.build()
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply {
detectWrongFragmentContainer()
detectFragmentTagUsage()
detectRetainInstanceUsage()
detectSetUserVisibleHint()
detectWrongNestedHierarchy()
detectFragmentReuse()
penaltyLog()
if (notifier != null) {
penaltyListener(notifier)
}
}.build()
}
}

View File

@@ -0,0 +1,73 @@
package org.koitharu.kotatsu
import android.app.Notification
import android.app.Notification.BigTextStyle
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.os.StrictMode
import android.os.strictmode.Violation
import androidx.annotation.RequiresApi
import androidx.core.app.PendingIntentCompat
import androidx.core.content.getSystemService
import androidx.fragment.app.strictmode.FragmentStrictMode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import org.koitharu.kotatsu.core.util.ShareHelper
import kotlin.math.absoluteValue
import androidx.fragment.app.strictmode.Violation as FragmentViolation
@RequiresApi(Build.VERSION_CODES.P)
class StrictModeNotifier(
private val context: Context,
) : StrictMode.OnVmViolationListener, StrictMode.OnThreadViolationListener, FragmentStrictMode.OnViolationListener {
val executor = Dispatchers.Default.asExecutor()
private val notificationManager by lazy {
val nm = checkNotNull(context.getSystemService<NotificationManager>())
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.strict_mode),
NotificationManager.IMPORTANCE_LOW,
)
nm.createNotificationChannel(channel)
nm
}
override fun onVmViolation(v: Violation) = showNotification(v)
override fun onThreadViolation(v: Violation) = showNotification(v)
override fun onViolation(violation: FragmentViolation) = showNotification(violation)
private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_bug)
.setContentTitle(context.getString(R.string.strict_mode))
.setContentText(violation.message)
.setStyle(
BigTextStyle()
.setBigContentTitle(context.getString(R.string.strict_mode))
.setSummaryText(violation.message)
.bigText(violation.stackTraceToString()),
).setShowWhen(true)
.setContentIntent(
PendingIntentCompat.getActivity(
context,
0,
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
0,
false,
),
)
.setAutoCancel(true)
.setGroup(CHANNEL_ID)
.build()
.let { notificationManager.notify(CHANNEL_ID, violation.hashCode().absoluteValue, it) }
private companion object {
const val CHANNEL_ID = "strict_mode"
}
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.network
import android.util.Log
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import okio.Buffer
import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
@@ -12,8 +13,11 @@ class CurlLoggingInterceptor(
private val escapeRegex = Regex("([\\[\\]\"])")
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request()).also {
logRequest(it.networkResponse?.request ?: it.request)
}
private fun logRequest(request: Request) {
var isCompressed = false
val curlCmd = StringBuilder()
@@ -46,16 +50,11 @@ class CurlLoggingInterceptor(
log("---cURL (" + request.url + ")")
log(curlCmd.toString())
return chain.proceed(request)
}
private fun String.escape() = replace(escapeRegex) { match ->
"\\" + match.value
}
// .replace("\"", "\\\"")
// .replace("[", "\\[")
// .replace("]", "\\]")
private fun log(msg: String) {
Log.d("CURL", msg)

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<group android:scaleX="0.98150784"
android:scaleY="0.98150784"
android:translateX="0.22190611"
android:translateY="-0.2688478">
<path
android:fillColor="@android:color/white"
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z"/>
</group>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 B

View File

@@ -1,3 +1,4 @@
<resources>
<string name="app_name" translatable="false">Kotatsu Dev</string>
</resources>
<string name="strict_mode">Strict mode</string>
</resources>

File diff suppressed because it is too large Load Diff

View File

@@ -14,18 +14,21 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
private const val MAX_PARALLELISM = 4
private const val MATCH_THRESHOLD = 0.2f
private const val MATCH_THRESHOLD_DEFAULT = 0.2f
class AlternativesUseCase @Inject constructor(
private val sourcesRepository: MangaSourcesRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
suspend operator fun invoke(manga: Manga): Flow<Manga> {
suspend operator fun invoke(manga: Manga): Flow<Manga> = invoke(manga, MATCH_THRESHOLD_DEFAULT)
suspend operator fun invoke(manga: Manga, matchThreshold: Float): Flow<Manga> {
val sources = getSources(manga.source)
if (sources.isEmpty()) {
return emptyFlow()
@@ -34,17 +37,17 @@ class AlternativesUseCase @Inject constructor(
return channelFlow {
for (source in sources) {
val repository = mangaRepositoryFactory.create(source)
if (!repository.isSearchSupported) {
if (!repository.filterCapabilities.isSearchSupported) {
continue
}
launch {
val list = runCatchingCancellable {
semaphore.withPermit {
repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
repository.getList(offset = 0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title))
}
}.getOrDefault(emptyList())
for (item in list) {
if (item.matches(manga)) {
if (item.matches(manga, matchThreshold)) {
send(item)
}
}
@@ -65,16 +68,16 @@ class AlternativesUseCase @Inject constructor(
return result
}
private fun Manga.matches(ref: Manga): Boolean {
return matchesTitles(title, ref.title) ||
matchesTitles(title, ref.altTitle) ||
matchesTitles(altTitle, ref.title) ||
matchesTitles(altTitle, ref.altTitle)
private fun Manga.matches(ref: Manga, threshold: Float): Boolean {
return matchesTitles(title, ref.title, threshold) ||
matchesTitles(title, ref.altTitle, threshold) ||
matchesTitles(altTitle, ref.title, threshold) ||
matchesTitles(altTitle, ref.altTitle, threshold)
}
private fun matchesTitles(a: String?, b: String?): Boolean {
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, MATCH_THRESHOLD)
private fun matchesTitles(a: String?, b: String?, threshold: Float): Boolean {
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold)
}
private fun MangaSource.priority(ref: MangaSource): Int {

View File

@@ -0,0 +1,93 @@
package org.koitharu.kotatsu.alternatives.domain
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.lastOrNull
import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.flow.withIndex
import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
class AutoFixUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val alternativesUseCase: AlternativesUseCase,
private val migrateUseCase: MigrateUseCase,
private val mangaDataRepository: MangaDataRepository,
) {
suspend operator fun invoke(mangaId: Long): Pair<Manga, Manga?> {
val seed = checkNotNull(mangaDataRepository.findMangaById(mangaId)) { "Manga $mangaId not found" }
.getDetailsSafe()
if (seed.isHealthy()) {
return seed to null // no fix required
}
val replacement = alternativesUseCase(seed, matchThreshold = 0.02f)
.filter { it.isHealthy() }
.runningFold<Manga, Manga?>(null) { best, candidate ->
if (best == null || best < candidate) {
candidate
} else {
best
}
}.selectLastWithTimeout(4, 40, TimeUnit.SECONDS)
migrateUseCase(seed, replacement ?: throw NoAlternativesException(ParcelableManga(seed)))
return seed to replacement
}
private suspend fun Manga.isHealthy(): Boolean = runCatchingCancellable {
val repo = mangaRepositoryFactory.create(source)
val details = if (this.chapters != null) this else repo.getDetails(this)
val firstChapter = details.chapters?.firstOrNull() ?: return@runCatchingCancellable false
val pageUrl = repo.getPageUrl(repo.getPages(firstChapter).first())
pageUrl.toHttpUrlOrNull() != null
}.getOrDefault(false)
private suspend fun Manga.getDetailsSafe() = runCatchingCancellable {
mangaRepositoryFactory.create(source).getDetails(this)
}.getOrDefault(this)
private operator fun Manga.compareTo(other: Manga) = chaptersCount().compareTo(other.chaptersCount())
@Suppress("UNCHECKED_CAST", "OPT_IN_USAGE")
private suspend fun <T> Flow<T>.selectLastWithTimeout(
minCount: Int,
timeout: Long,
timeUnit: TimeUnit
): T? = channelFlow<T?> {
var lastValue: T? = null
launch {
delay(timeUnit.toMillis(timeout))
close(InternalTimeoutException(lastValue))
}
withIndex().transformWhile { (index, value) ->
lastValue = value
emit(value)
index < minCount && !isClosedForSend
}.collect {
send(it)
}
}.catch { e ->
if (e is InternalTimeoutException) {
emit(e.value as T?)
} else {
throw e
}
}.lastOrNull()
class NoAlternativesException(val seed: ParcelableManga) : NoSuchElementException()
private class InternalTimeoutException(val value: Any?) : CancellationException()
}

View File

@@ -136,7 +136,7 @@ constructor(
return HistoryEntity(
mangaId = newManga.id,
createdAt = history.createdAt,
updatedAt = System.currentTimeMillis(),
updatedAt = history.updatedAt,
chapterId = currentChapter.id,
page = history.page,
scroll = history.scroll,
@@ -173,7 +173,7 @@ constructor(
return HistoryEntity(
mangaId = newManga.id,
createdAt = history.createdAt,
updatedAt = System.currentTimeMillis(),
updatedAt = history.updatedAt,
chapterId = newChapterId,
page = history.page,
scroll = history.scroll,

View File

@@ -5,9 +5,16 @@ import androidx.core.content.ContextCompat
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import coil.request.ImageRequest
import coil.transform.RoundedCornersTransformation
import coil3.ImageLoader
import coil3.request.ImageRequest
import coil3.request.allowRgb565
import coil3.request.crossfade
import coil3.request.error
import coil3.request.fallback
import coil3.request.lifecycle
import coil3.request.placeholder
import coil3.request.transformations
import coil3.transform.RoundedCornersTransformation
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getTitle
@@ -19,8 +26,9 @@ import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.mangaExtra
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -74,7 +82,7 @@ fun alternativeAD(
.placeholder(R.drawable.ic_web)
.fallback(R.drawable.ic_web)
.error(R.drawable.ic_web)
.source(item.manga.source)
.mangaSourceExtra(item.manga.source)
.transformations(RoundedCornersTransformation(context.resources.getDimension(R.dimen.chip_icon_corner)))
.allowRgb565(true)
.enqueueWith(coil)
@@ -84,8 +92,7 @@ fun alternativeAD(
defaultPlaceholders(context)
transformations(TrimTransformation())
allowRgb565(true)
tag(item.manga)
source(item.manga.source)
mangaExtra(item.manga)
enqueueWith(coil)
}
}

View File

@@ -8,7 +8,7 @@ import android.widget.Toast
import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import coil.ImageLoader
import coil3.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
@@ -30,7 +30,8 @@ import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.search.ui.MangaListActivity
import javax.inject.Inject
@AndroidEntryPoint
@@ -81,7 +82,14 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
override fun onItemClick(item: MangaAlternativeModel, view: View) {
when (view.id) {
R.id.chip_source -> startActivity(SearchActivity.newIntent(this, item.manga.source, viewModel.manga.title))
R.id.chip_source -> startActivity(
MangaListActivity.newIntent(
this,
item.manga.source,
MangaListFilter(query = viewModel.manga.title),
),
)
R.id.button_migrate -> confirmMigration(item.manga)
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
}

View File

@@ -0,0 +1,193 @@
package org.koitharu.kotatsu.alternatives.ui
import android.annotation.SuppressLint
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import coil3.ImageLoader
import coil3.request.ImageRequest
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
import org.koitharu.kotatsu.core.ErrorReporterReceiver
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint
class AutoFixService : CoroutineIntentService() {
@Inject
lateinit var autoFixUseCase: AutoFixUseCase
@Inject
lateinit var coil: ImageLoader
private lateinit var notificationManager: NotificationManagerCompat
override fun onCreate() {
super.onCreate()
notificationManager = NotificationManagerCompat.from(applicationContext)
}
override suspend fun IntentJobContext.processIntent(intent: Intent) {
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
startForeground(this)
for (mangaId in ids) {
val result = runCatchingCancellable {
autoFixUseCase.invoke(mangaId)
}
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
val notification = buildNotification(result)
notificationManager.notify(TAG, startId, notification)
}
}
}
override fun IntentJobContext.onError(error: Throwable) {
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
val notification = runBlocking { buildNotification(Result.failure(error)) }
notificationManager.notify(TAG, startId, notification)
}
}
@SuppressLint("InlinedApi")
private fun startForeground(jobContext: IntentJobContext) {
val title = applicationContext.getString(R.string.fixing_manga)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
.setName(title)
.setShowBadge(false)
.setVibrationEnabled(false)
.setSound(null, null)
.setLightsEnabled(false)
.build()
notificationManager.createNotificationChannel(channel)
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(title)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setDefaults(0)
.setSilent(true)
.setOngoing(true)
.setProgress(0, 0, true)
.setSmallIcon(R.drawable.ic_stat_auto_fix)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.addAction(
materialR.drawable.material_ic_clear_black_24dp,
applicationContext.getString(android.R.string.cancel),
jobContext.getCancelIntent(),
)
.build()
jobContext.setForeground(
FOREGROUND_NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
}
private suspend fun buildNotification(result: Result<Pair<Manga, Manga?>>): Notification {
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setDefaults(0)
.setSilent(true)
.setAutoCancel(true)
result.onSuccess { (seed, replacement) ->
if (replacement != null) {
notification.setLargeIcon(
coil.execute(
ImageRequest.Builder(applicationContext)
.data(replacement.coverUrl)
.mangaSourceExtra(replacement.source)
.build(),
).toBitmapOrNull(),
)
notification.setSubText(replacement.title)
val intent = DetailsActivity.newIntent(applicationContext, replacement)
notification.setContentIntent(
PendingIntentCompat.getActivity(
applicationContext,
replacement.id.toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT,
false,
),
).setVisibility(
if (replacement.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC,
)
notification
.setContentTitle(applicationContext.getString(R.string.fixed))
.setContentText(
applicationContext.getString(
R.string.manga_replaced,
seed.title,
seed.source.getTitle(applicationContext),
replacement.title,
replacement.source.getTitle(applicationContext),
),
)
.setSmallIcon(R.drawable.ic_stat_done)
} else {
notification
.setContentTitle(applicationContext.getString(R.string.fixing_manga))
.setContentText(applicationContext.getString(R.string.no_fix_required, seed.title))
.setSmallIcon(android.R.drawable.stat_sys_warning)
}
}.onFailure { error ->
notification
.setContentTitle(applicationContext.getString(R.string.error_occurred))
.setContentText(
if (error is AutoFixUseCase.NoAlternativesException) {
applicationContext.getString(R.string.no_alternatives_found, error.seed.manga.title)
} else {
error.getDisplayMessage(applicationContext.resources)
},
).setSmallIcon(android.R.drawable.stat_notify_error)
ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent ->
notification.addAction(
R.drawable.ic_alert_outline,
applicationContext.getString(R.string.report),
reportIntent,
)
}
}
return notification.build()
}
companion object {
private const val DATA_IDS = "ids"
private const val TAG = "auto_fix"
private const val CHANNEL_ID = "auto_fix"
private const val FOREGROUND_NOTIFICATION_ID = 38
fun start(context: Context, mangaIds: Collection<Long>): Boolean = try {
val intent = Intent(context, AutoFixService::class.java)
intent.putExtra(DATA_IDS, mangaIds.toLongArray())
ContextCompat.startForegroundService(context, intent)
true
} catch (e: Exception) {
e.printStackTraceDebug()
false
}
}
}

View File

@@ -17,9 +17,6 @@ data class Bookmark(
val percent: Float,
) : ListModel {
val directImageUrl: String?
get() = if (isImageUrlDirect()) imageUrl else null
val imageLoadData: Any
get() = if (isImageUrlDirect()) imageUrl else toMangaPage()

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.bookmarks.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
@@ -13,7 +14,7 @@ import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
import coil.ImageLoader
import coil3.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
@@ -129,7 +130,11 @@ class AllBookmarksFragment :
}
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
return selectionController?.onItemLongClick(item.pageId) ?: false
return selectionController?.onItemLongClick(view, item.pageId) ?: false
}
override fun onItemContextClick(item: Bookmark, view: View): Boolean {
return selectionController?.onItemContextClick(view, item.pageId) ?: false
}
override fun onRetryClick(error: Throwable) = Unit
@@ -148,23 +153,23 @@ class AllBookmarksFragment :
override fun onCreateActionMode(
controller: ListSelectionController,
mode: ActionMode,
menuInflater: MenuInflater,
menu: Menu,
): Boolean {
mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
menuInflater.inflate(R.menu.mode_bookmarks, menu)
return true
}
override fun onActionItemClicked(
controller: ListSelectionController,
mode: ActionMode,
mode: ActionMode?,
item: MenuItem,
): Boolean {
return when (item.itemId) {
R.id.action_remove -> {
val ids = selectionController?.snapshot() ?: return false
viewModel.removeBookmarks(ids)
mode.finish()
mode?.finish()
true
}

View File

@@ -1,17 +1,18 @@
package org.koitharu.kotatsu.bookmarks.ui.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import coil3.ImageLoader
import coil3.request.allowRgb565
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.bookmarkExtra
import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -22,19 +23,15 @@ fun bookmarkLargeAD(
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
) {
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
binding.root.setOnClickListener(listener)
binding.root.setOnLongClickListener(listener)
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
bind {
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
size(CoverSizeResolver(binding.imageViewThumb))
defaultPlaceholders(context)
allowRgb565(true)
tag(item)
bookmarkExtra(item)
decodeRegion(item.scroll)
source(item.manga.source)
enqueueWith(coil)
}
binding.progressView.setProgress(item.percent, false)

View File

@@ -1,19 +1,21 @@
package org.koitharu.kotatsu.bookmarks.ui.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import coil3.ImageLoader
import coil3.request.allowRgb565
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.bookmarkExtra
import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
// TODO check usages
fun bookmarkListAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
@@ -21,19 +23,15 @@ fun bookmarkListAD(
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) },
) {
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
binding.root.setOnClickListener(listener)
binding.root.setOnLongClickListener(listener)
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
bind {
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
size(CoverSizeResolver(binding.imageViewThumb))
defaultPlaceholders(context)
allowRgb565(true)
tag(item)
bookmarkExtra(item)
decodeRegion(item.scroll)
source(item.manga.source)
enqueueWith(coil)
}
}

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.bookmarks.ui.adapter
import android.content.Context
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import coil3.ImageLoader
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener

View File

@@ -12,7 +12,6 @@ import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint
import okhttp3.internal.userAgent
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.network.CommonHeaders
@@ -45,7 +44,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
}
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
viewBinding.webView.configureForParser(userAgent)
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
viewBinding.webView.webViewClient = BrowserClient(this)

View File

@@ -9,9 +9,10 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.net.toUri
import coil.EventListener
import coil.request.ErrorResult
import coil.request.ImageRequest
import coil3.EventListener
import coil3.Extras
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.getTitle
@@ -21,14 +22,14 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
class CaptchaNotifier(
private val context: Context,
) : EventListener {
) : EventListener() {
fun notify(exception: CloudFlareProtectedException) {
if (!context.checkNotificationPermission(CHANNEL_ID)) {
return
}
val manager = NotificationManagerCompat.from(context)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(context.getString(R.string.captcha_required))
.setShowBadge(true)
.setVibrationEnabled(false)
@@ -41,9 +42,9 @@ class CaptchaNotifier(
.setData(exception.url.toUri())
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(channel.name)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setDefaults(NotificationCompat.DEFAULT_SOUND)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setDefaults(0)
.setSmallIcon(R.drawable.ic_bot)
.setGroup(GROUP_CAPTCHA)
.setAutoCancel(true)
.setVisibility(
@@ -84,20 +85,19 @@ class CaptchaNotifier(
override fun onError(request: ImageRequest, result: ErrorResult) {
super.onError(request, result)
val e = result.throwable
if (e is CloudFlareProtectedException && request.parameters.value<Boolean>(PARAM_IGNORE_CAPTCHA) != true) {
if (e is CloudFlareProtectedException && request.extras[ignoreCaptchaKey] != true) {
notify(e)
}
}
companion object {
fun ImageRequest.Builder.ignoreCaptchaErrors() = setParameter(
key = PARAM_IGNORE_CAPTCHA,
value = true,
memoryCacheKey = null,
)
fun ImageRequest.Builder.ignoreCaptchaErrors() = apply {
extras[ignoreCaptchaKey] = true
}
val ignoreCaptchaKey = Extras.Key(false)
private const val PARAM_IGNORE_CAPTCHA = "ignore_captcha"
private const val CHANNEL_ID = "captcha"
private const val TAG = CHANNEL_ID
private const val GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA"

View File

@@ -32,6 +32,7 @@ import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -175,8 +176,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
cookieJar.removeCookies(url) { cookie ->
val name = cookie.name
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf") || name == "csrftoken"
CloudFlareHelper.isCloudFlareCookie(cookie.name)
}
}

View File

@@ -2,11 +2,10 @@ package org.koitharu.kotatsu.browser.cloudflare
import android.graphics.Bitmap
import android.webkit.WebView
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.browser.BrowserClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
private const val CF_CLEARANCE = "cf_clearance"
private const val LOOP_COUNTER = 3
class CloudFlareClient(
@@ -50,8 +49,5 @@ class CloudFlareClient(
}
}
private fun getClearance(): String? {
return cookieJar.loadForRequest(targetUrl.toHttpUrl())
.find { it.name == CF_CLEARANCE }?.value
}
private fun getClearance() = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl)
}

View File

@@ -1,18 +1,22 @@
package org.koitharu.kotatsu.core
import android.app.Application
import android.content.ContentResolver
import android.content.Context
import android.os.Build
import android.provider.SearchRecentSuggestions
import android.text.Html
import androidx.collection.arraySetOf
import androidx.room.InvalidationTracker
import androidx.work.WorkManager
import coil.ComponentRegistry
import coil.ImageLoader
import coil.decode.SvgDecoder
import coil.disk.DiskCache
import coil.util.DebugLogger
import coil3.ImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.allowRgb565
import coil3.svg.SvgDecoder
import coil3.util.DebugLogger
import dagger.Binds
import dagger.Module
import dagger.Provides
@@ -28,6 +32,9 @@ import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.image.AvifImageDecoder
import org.koitharu.kotatsu.core.image.CbzFetcher
import org.koitharu.kotatsu.core.image.MangaSourceHeaderInterceptor
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.os.AppShortcutManager
@@ -44,7 +51,6 @@ import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageFetcher
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
@@ -81,9 +87,7 @@ interface AppModule {
@Singleton
fun provideMangaDatabase(
@ApplicationContext context: Context,
): MangaDatabase {
return MangaDatabase(context)
}
): MangaDatabase = MangaDatabase(context)
@Provides
@Singleton
@@ -94,6 +98,7 @@ interface AppModule {
imageProxyInterceptor: ImageProxyInterceptor,
pageFetcherFactory: MangaPageFetcher.Factory,
coverRestoreInterceptor: CoverRestoreInterceptor,
networkStateProvider: Provider<NetworkState>,
): ImageLoader {
val diskCacheFactory = {
val rootDir = context.externalCacheDir ?: context.cacheDir
@@ -105,36 +110,39 @@ interface AppModule {
okHttpClientProvider.get().newBuilder().cache(null).build()
}
return ImageLoader.Builder(context)
.okHttpClient { okHttpClientLazy.value }
.interceptorDispatcher(Dispatchers.Default)
.fetcherDispatcher(Dispatchers.Default)
.decoderDispatcher(Dispatchers.IO)
.transformationDispatcher(Dispatchers.Default)
.interceptorCoroutineContext(Dispatchers.Default)
.diskCache(diskCacheFactory)
.respectCacheHeaders(false)
.networkObserverEnabled(false)
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
.allowRgb565(context.isLowRamDevice())
.eventListener(CaptchaNotifier(context))
.components(
ComponentRegistry.Builder()
.add(SvgDecoder.Factory())
.add(CbzFetcher.Factory())
.add(FaviconFetcher.Factory(context, okHttpClientLazy, mangaRepositoryFactory))
.add(MangaPageKeyer())
.add(pageFetcherFactory)
.add(imageProxyInterceptor)
.add(coverRestoreInterceptor)
.build(),
).build()
.components {
add(
OkHttpNetworkFetcherFactory(
callFactory = okHttpClientLazy::value,
connectivityChecker = { networkStateProvider.get() },
),
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
add(AnimatedImageDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
add(CbzFetcher.Factory())
add(AvifImageDecoder.Factory())
add(FaviconFetcher.Factory(mangaRepositoryFactory))
add(MangaPageKeyer())
add(pageFetcherFactory)
add(imageProxyInterceptor)
add(coverRestoreInterceptor)
add(MangaSourceHeaderInterceptor())
}.build()
}
@Provides
fun provideSearchSuggestions(
@ApplicationContext context: Context,
): SearchRecentSuggestions {
return MangaSuggestionsProvider.createSuggestions(context)
}
): SearchRecentSuggestions = MangaSuggestionsProvider.createSuggestions(context)
@Provides
@ElementsIntoSet

View File

@@ -11,6 +11,7 @@ import androidx.work.Configuration
import androidx.work.WorkManager
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.acra.ACRA
@@ -28,6 +29,9 @@ import org.koitharu.kotatsu.core.os.AppValidator
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
import java.security.Security
import javax.inject.Inject
@@ -60,6 +64,13 @@ open class BaseApp : Application(), Configuration.Provider {
@Inject
lateinit var workManagerProvider: Provider<WorkManager>
@Inject
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>
@Inject
@LocalStorageChanges
lateinit var localStorageChanges: MutableSharedFlow<LocalManga?>
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
@@ -67,6 +78,9 @@ open class BaseApp : Application(), Configuration.Provider {
override fun onCreate() {
super.onCreate()
if (ACRA.isACRASenderServiceProcess()) {
return
}
AppCompatDelegate.setDefaultNightMode(settings.theme)
AppCompatDelegate.setApplicationLocales(settings.appLocales)
// TLS 1.3 support for Android < 10
@@ -82,6 +96,7 @@ open class BaseApp : Application(), Configuration.Provider {
}
processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers()
localStorageChanges.collect(localMangaIndexProvider.get())
}
workScheduleManager.init()
WorkServiceStopHelper(workManagerProvider).setup()

View File

@@ -5,9 +5,11 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.BadParcelableException
import androidx.core.app.PendingIntentCompat
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.report
class ErrorReporterReceiver : BroadcastReceiver() {
@@ -22,12 +24,15 @@ class ErrorReporterReceiver : BroadcastReceiver() {
private const val EXTRA_ERROR = "err"
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
fun getPendingIntent(context: Context, e: Throwable): PendingIntent {
fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = try {
val intent = Intent(context, ErrorReporterReceiver::class.java)
intent.setAction(ACTION_REPORT)
intent.setData(Uri.parse("err://${e.hashCode()}"))
intent.putExtra(EXTRA_ERROR, e)
return checkNotNull(PendingIntentCompat.getBroadcast(context, 0, intent, 0, false))
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
} catch (e: BadParcelableException) {
e.printStackTraceDebug()
null
}
}
}

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.backup
import android.net.Uri
import java.util.Date
data class BackupFile(
val uri: Uri,
val dateTime: Date,
): Comparable<BackupFile> {
override fun compareTo(other: BackupFile): Int = compareValues(dateTime, other.dateTime)
}

View File

@@ -6,7 +6,7 @@ import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.json.JSONIterator
import org.koitharu.kotatsu.parsers.util.json.asTypedList
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -130,7 +130,7 @@ class BackupRepository @Inject constructor(
suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
for (item in entry.data.asTypedList<JSONObject>()) {
val mangaJson = item.getJSONObject("manga")
val manga = JsonDeserializer(mangaJson).toMangaEntity()
val tags = mangaJson.getJSONArray("tags").mapJSON {
@@ -150,7 +150,7 @@ class BackupRepository @Inject constructor(
suspend fun restoreCategories(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
for (item in entry.data.asTypedList<JSONObject>()) {
val category = JsonDeserializer(item).toFavouriteCategoryEntity()
result += runCatchingCancellable {
db.getFavouriteCategoriesDao().upsert(category)
@@ -161,7 +161,7 @@ class BackupRepository @Inject constructor(
suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
for (item in entry.data.asTypedList<JSONObject>()) {
val mangaJson = item.getJSONObject("manga")
val manga = JsonDeserializer(mangaJson).toMangaEntity()
val tags = mangaJson.getJSONArray("tags").mapJSON {
@@ -181,7 +181,7 @@ class BackupRepository @Inject constructor(
suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
for (item in entry.data.asTypedList<JSONObject>()) {
val mangaJson = item.getJSONObject("manga")
val manga = JsonDeserializer(mangaJson).toMangaEntity()
val tags = item.getJSONArray("tags").mapJSON {
@@ -203,7 +203,7 @@ class BackupRepository @Inject constructor(
suspend fun restoreSources(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
for (item in entry.data.asTypedList<JSONObject>()) {
val source = JsonDeserializer(item).toMangaSourceEntity()
result += runCatchingCancellable {
db.getSourcesDao().upsert(source)
@@ -214,7 +214,7 @@ class BackupRepository @Inject constructor(
fun restoreSettings(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
for (item in entry.data.asTypedList<JSONObject>()) {
result += runCatchingCancellable {
settings.upsertAll(JsonDeserializer(item).toMap())
}

View File

@@ -1,13 +1,11 @@
package org.koitharu.kotatsu.core.backup
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import okhttp3.internal.closeQuietly
import okio.Closeable
import org.json.JSONArray
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import java.io.File
import java.util.EnumSet
import java.util.zip.ZipException
@@ -35,25 +33,29 @@ class BackupZipInput private constructor(val file: File) : Closeable {
zipFile.close()
}
fun cleanupAsync() {
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
runCatching {
close()
file.delete()
}
}
fun closeAndDelete() {
closeQuietly()
file.delete()
}
companion object {
fun from(file: File): BackupZipInput = try {
val res = BackupZipInput(file)
if (res.zipFile.getEntry("index") == null) {
throw BadBackupFormatException(null)
fun from(file: File): BackupZipInput {
var res: BackupZipInput? = null
return try {
res = BackupZipInput(file)
if (res.zipFile.getEntry("index") == null) {
throw BadBackupFormatException(null)
}
res
} catch (exception: Throwable) {
res?.closeQuietly()
throw if (exception is ZipException) {
BadBackupFormatException(exception)
} else {
exception
}
}
res
} catch (e: ZipException) {
throw BadBackupFormatException(e)
}
}
}

View File

@@ -5,10 +5,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.Closeable
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.zip.ZipOutput
import java.io.File
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.Deflater
@@ -27,20 +29,32 @@ class BackupZipOutput(val file: File) : Closeable {
override fun close() {
output.close()
}
}
const val DIR_BACKUPS = "backups"
companion object {
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
val dir = context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
const val DIR_BACKUPS = "backups"
private val dateTimeFormat = SimpleDateFormat("yyyyMMdd-HHmm")
fun generateFileName(context: Context) = buildString {
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
append('_')
append(dateTimeFormat.format(Date()))
append(".bk.zip")
}
fun parseBackupDateTime(fileName: String): Date? = try {
dateTimeFormat.parse(fileName.substringAfterLast('_').substringBefore('.'))
} catch (e: ParseException) {
e.printStackTraceDebug()
null
}
suspend fun createTemp(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
val dir = context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
}
dir.mkdirs()
BackupZipOutput(File(dir, generateFileName(context)))
}
}
dir.mkdirs()
val filename = buildString {
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
append('_')
append(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")))
append(".bk.zip")
}
BackupZipOutput(File(dir, filename))
}

View File

@@ -0,0 +1,91 @@
package org.koitharu.kotatsu.core.backup
import android.content.Context
import android.net.Uri
import androidx.annotation.CheckResult
import androidx.documentfile.provider.DocumentFile
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.buffer
import okio.sink
import okio.source
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File
import javax.inject.Inject
class ExternalBackupStorage @Inject constructor(
@ApplicationContext private val context: Context,
private val settings: AppSettings,
) {
suspend fun list(): List<BackupFile> = runInterruptible(Dispatchers.IO) {
getRootOrThrow().listFiles().mapNotNull {
if (it.isFile && it.canRead()) {
BackupFile(
uri = it.uri,
dateTime = it.name?.let { fileName ->
BackupZipOutput.parseBackupDateTime(fileName)
} ?: return@mapNotNull null,
)
} else {
null
}
}.sortedDescending()
}
suspend fun listOrNull() = runCatchingCancellable {
list()
}.onFailure { e ->
e.printStackTraceDebug()
}.getOrNull()
suspend fun put(file: File): Uri = runInterruptible(Dispatchers.IO) {
val out = checkNotNull(getRootOrThrow().createFile("application/zip", file.nameWithoutExtension)) {
"Cannot create target backup file"
}
checkNotNull(context.contentResolver.openOutputStream(out.uri, "wt")).sink().use { sink ->
file.source().buffer().use { src ->
src.readAll(sink)
}
}
out.uri
}
@CheckResult
suspend fun delete(victim: BackupFile) = runInterruptible(Dispatchers.IO) {
val df = DocumentFile.fromSingleUri(context, victim.uri)
df != null && df.delete()
}
suspend fun getLastBackupDate() = listOrNull()?.maxOfOrNull { it.dateTime }
suspend fun trim(maxCount: Int): Boolean {
if (maxCount == Int.MAX_VALUE) {
return false
}
val list = listOrNull()
if (list == null || list.size <= maxCount) {
return false
}
var result = false
for (i in maxCount until list.size) {
if (delete(list[i])) {
result = true
}
}
return result
}
@Blocking
private fun getRootOrThrow(): DocumentFile {
val uri = checkNotNull(settings.periodicalBackupDirectory) {
"Backup directory is not specified"
}
val root = DocumentFile.fromTreeUri(context, uri)
return checkNotNull(root) { "Cannot obtain DocumentFile from $uri" }
}
}

View File

@@ -35,6 +35,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration19To20
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
import org.koitharu.kotatsu.core.db.migrations.Migration21To22
import org.koitharu.kotatsu.core.db.migrations.Migration22To23
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
@@ -50,6 +51,8 @@ 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.local.data.index.LocalMangaIndexDao
import org.koitharu.kotatsu.local.data.index.LocalMangaIndexEntity
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
import org.koitharu.kotatsu.stats.data.StatsDao
@@ -60,14 +63,14 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 22
const val DATABASE_VERSION = 23
@Database(
entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class,
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class,
],
version = DATABASE_VERSION,
)
@@ -98,6 +101,8 @@ abstract class MangaDatabase : RoomDatabase() {
abstract fun getSourcesDao(): MangaSourcesDao
abstract fun getStatsDao(): StatsDao
abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao
}
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
@@ -122,6 +127,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration19To20(),
Migration20To21(),
Migration21To22(),
Migration22To23(),
)
fun MangaDatabase(context: Context): MangaDatabase = Room

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.core.db.dao
import androidx.room.*
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
@@ -13,6 +15,9 @@ abstract class PreferencesDao {
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?>
@Query("UPDATE preferences SET cf_brightness = 0, cf_contrast = 0, cf_invert = 0, cf_grayscale = 0")
abstract suspend fun resetColorFilters()
@Upsert
abstract suspend fun upsert(pref: MangaPrefsEntity)
}

View File

@@ -39,6 +39,8 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
fun Collection<MangaWithTags>.toMangaList() = map { it.toManga() }
// Model to entity
fun Manga.toEntity() = MangaEntity(
@@ -47,7 +49,7 @@ fun Manga.toEntity() = MangaEntity(
publicUrl = publicUrl,
source = source.name,
largeCoverUrl = largeCoverUrl,
coverUrl = coverUrl,
coverUrl = coverUrl.orEmpty(),
altTitle = altTitle,
rating = rating,
isNsfw = isNsfw,

View File

@@ -14,7 +14,7 @@ data class MangaEntity(
@ColumnInfo(name = "url") val url: String,
@ColumnInfo(name = "public_url") val publicUrl: String,
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
@ColumnInfo(name = "nsfw") val isNsfw: Boolean,
@ColumnInfo(name = "nsfw") val isNsfw: Boolean, // TODO change to contentRating
@ColumnInfo(name = "cover_url") val coverUrl: String,
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
@ColumnInfo(name = "state") val state: String?,

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration22To23 : Migration(22, 23) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS `local_index` (`manga_id` INTEGER NOT NULL, `path` TEXT NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
}
}

View File

@@ -1,3 +1,5 @@
package org.koitharu.kotatsu.core.exceptions
class CaughtException(cause: Throwable, override val message: String?) : RuntimeException(cause)
class CaughtException(
override val cause: Throwable
) : RuntimeException("${cause.javaClass.simpleName}(${cause.message})", cause)

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.exceptions
import okhttp3.Headers
import okio.IOException
import org.koitharu.kotatsu.parsers.model.MangaSource

View File

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

View File

@@ -0,0 +1,5 @@
package org.koitharu.kotatsu.core.exceptions
import okio.IOException
class WrapperIOException(override val cause: Exception) : IOException(cause)

View File

@@ -8,6 +8,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.parsers.exception.ParseException
class DialogErrorObserver(
@@ -32,7 +33,7 @@ class DialogErrorObserver(
dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener)
} else if (value is ParseException) {
val fm = fragmentManager
if (fm != null) {
if (fm != null && value.isSerializable()) {
dialogBuilder.setPositiveButton(R.string.details) { _, _ ->
ErrorDetailsDialog.show(fm, value, value.url)
}

View File

@@ -6,7 +6,6 @@ import androidx.activity.result.ActivityResultCaller
import androidx.annotation.StringRes
import androidx.collection.MutableScatterMap
import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@@ -19,7 +18,8 @@ import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.util.ext.restartApplication
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
@@ -124,15 +124,16 @@ class ExceptionResolver @AssistedInject constructor(
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
return
}
MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.ignore_ssl_errors)
.setMessage(R.string.ignore_ssl_errors_summary)
.setPositiveButton(R.string.apply) { _, _ ->
buildAlertDialog(ctx) {
setTitle(R.string.ignore_ssl_errors)
setMessage(R.string.ignore_ssl_errors_summary)
setPositiveButton(R.string.apply) { _, _ ->
settings.isSSLBypassEnabled = true
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_SHORT).show()
ctx.findActivity()?.finishAffinity()
}.setNegativeButton(android.R.string.cancel, null)
.show()
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
ctx.restartApplication()
}
setNegativeButton(android.R.string.cancel, null)
}.show()
}
private inline fun Host.withContext(block: Context.() -> Unit) {

View File

@@ -7,6 +7,7 @@ import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.parsers.exception.ParseException
@@ -33,7 +34,7 @@ class SnackbarErrorObserver(
}
} else if (value is ParseException) {
val fm = fragmentManager
if (fm != null) {
if (fm != null && value.isSerializable()) {
snackbar.setAction(R.string.details) {
ErrorDetailsDialog.show(fm, value, value.url)
}

View File

@@ -1,20 +1,31 @@
package org.koitharu.kotatsu.core.fs
import android.os.Build
import org.koitharu.kotatsu.core.util.iterator.CloseableIterator
import androidx.annotation.RequiresApi
import org.koitharu.kotatsu.core.util.CloseableSequence
import org.koitharu.kotatsu.core.util.iterator.MappingIterator
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
class FileSequence(private val dir: File) : Sequence<File> {
sealed interface FileSequence : CloseableSequence<File> {
override fun iterator(): Iterator<File> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val stream = Files.newDirectoryStream(dir.toPath())
CloseableIterator(MappingIterator(stream.iterator(), Path::toFile), stream)
} else {
dir.listFiles().orEmpty().iterator()
}
@RequiresApi(Build.VERSION_CODES.O)
class StreamImpl(dir: File) : FileSequence {
private val stream = Files.newDirectoryStream(dir.toPath())
override fun iterator(): Iterator<File> = MappingIterator(stream.iterator(), Path::toFile)
override fun close() = stream.close()
}
class ListImpl(dir: File) : FileSequence {
private val list = dir.listFiles().orEmpty()
override fun iterator(): Iterator<File> = list.iterator()
override fun close() = Unit
}
}

View File

@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.core.github
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -9,6 +11,7 @@ import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.BaseHttpClient
import org.koitharu.kotatsu.core.os.AppValidator
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -22,22 +25,29 @@ import javax.inject.Inject
import javax.inject.Singleton
private const val CONTENT_TYPE_APK = "application/vnd.android.package-archive"
private const val BUILD_TYPE_RELEASE = "release"
@Singleton
class AppUpdateRepository @Inject constructor(
private val appValidator: AppValidator,
private val settings: AppSettings,
@BaseHttpClient private val okHttp: OkHttpClient,
@ApplicationContext context: Context,
) {
private val availableUpdate = MutableStateFlow<AppVersion?>(null)
private val releasesUrl = buildString {
append("https://api.github.com/repos/")
append(context.getString(R.string.github_updates_repo))
append("/releases?page=1&per_page=10")
}
fun observeAvailableUpdate() = availableUpdate.asStateFlow()
suspend fun getAvailableVersions(): List<AppVersion> {
val request = Request.Builder()
.get()
.url("https://api.github.com/repos/KotatsuApp/Kotatsu/releases?page=1&per_page=10")
.url(releasesUrl)
val jsonArray = okHttp.newCall(request.build()).await().parseJsonArray()
return jsonArray.mapJSONNotNull { json ->
val asset = json.optJSONArray("assets")?.find { jo ->
@@ -74,8 +84,9 @@ class AppUpdateRepository @Inject constructor(
}.getOrNull()
}
@Suppress("KotlinConstantConditions")
fun isUpdateSupported(): Boolean {
return BuildConfig.DEBUG || appValidator.isOriginalApp
return BuildConfig.BUILD_TYPE != BUILD_TYPE_RELEASE || appValidator.isOriginalApp
}
suspend fun getCurrentVersionChangelog(): String? {

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.github
import java.util.*
import org.koitharu.kotatsu.core.util.ext.digits
import java.util.Locale
data class VersionId(
val major: Int,
@@ -43,6 +44,16 @@ val VersionId.isStable: Boolean
get() = variantType.isEmpty()
fun VersionId(versionName: String): VersionId {
if (versionName.startsWith('n', ignoreCase = true)) {
// Nightly build
return VersionId(
major = 0,
minor = 0,
build = versionName.digits().toIntOrNull() ?: 0,
variantType = "n",
variantNumber = 0,
)
}
val parts = versionName.substringBeforeLast('-').split('.')
val variant = versionName.substringAfterLast('-', "")
return VersionId(

View File

@@ -0,0 +1,66 @@
package org.koitharu.kotatsu.core.image
import android.graphics.Bitmap
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DecodeResult
import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import kotlinx.coroutines.runInterruptible
import org.aomedia.avif.android.AvifDecoder
import org.aomedia.avif.android.AvifDecoder.Info
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
class AvifImageDecoder(
private val source: ImageSource,
private val options: Options,
) : Decoder {
override suspend fun decode(): DecodeResult = runInterruptible {
val bytes = source.source().use {
it.inputStream().toByteBuffer()
}
val info = Info()
if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) {
throw ImageDecodeException(
null,
"avif",
"Requested to decode byte buffer which cannot be handled by AvifDecoder",
)
}
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
val bitmap = Bitmap.createBitmap(info.width, info.height, config)
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
bitmap.recycle()
throw ImageDecodeException(null, "avif")
}
DecodeResult(
image = bitmap.asImage(),
isSampled = false,
)
}
class Factory : Decoder.Factory {
override fun create(
result: SourceFetchResult,
options: Options,
imageLoader: ImageLoader
): Decoder? = if (isApplicable(result)) {
AvifImageDecoder(result.source, options)
} else {
null
}
override fun equals(other: Any?) = other is Factory
override fun hashCode() = javaClass.hashCode()
private fun isApplicable(result: SourceFetchResult): Boolean {
return result.mimeType == "image/avif"
}
}
}

View File

@@ -0,0 +1,94 @@
package org.koitharu.kotatsu.core.image
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.os.Build
import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import org.aomedia.avif.android.AvifDecoder
import org.aomedia.avif.android.AvifDecoder.Info
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
import java.io.File
import java.io.InputStream
import java.nio.ByteBuffer
import java.nio.file.Files
object BitmapDecoderCompat {
private const val FORMAT_AVIF = "avif"
@Blocking
fun decode(file: File): Bitmap = when (val format = getMimeType(file)?.subtype) {
FORMAT_AVIF -> file.inputStream().use { decodeAvif(it.toByteBuffer()) }
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(file))
} else {
checkBitmapNotNull(BitmapFactory.decodeFile(file.absolutePath), format)
}
}
@Blocking
fun decode(stream: InputStream, type: MediaType?, isMutable: Boolean = false): Bitmap {
val format = type?.subtype
if (format == FORMAT_AVIF) {
return decodeAvif(stream.toByteBuffer())
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
val opts = BitmapFactory.Options()
opts.inMutable = isMutable
return checkBitmapNotNull(BitmapFactory.decodeStream(stream, null, opts), format)
}
val byteBuffer = stream.toByteBuffer()
return if (AvifDecoder.isAvifImage(byteBuffer)) {
decodeAvif(byteBuffer)
} else {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(byteBuffer), DecoderConfigListener(isMutable))
}
}
private fun getMimeType(file: File): MediaType? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Files.probeContentType(file.toPath())?.toMediaTypeOrNull()
} else {
MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)?.toMediaTypeOrNull()
}
private fun checkBitmapNotNull(bitmap: Bitmap?, format: String?): Bitmap =
bitmap ?: throw ImageDecodeException(null, format)
private fun decodeAvif(bytes: ByteBuffer): Bitmap {
val info = Info()
if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) {
throw ImageDecodeException(
null,
FORMAT_AVIF,
"Requested to decode byte buffer which cannot be handled by AvifDecoder",
)
}
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
val bitmap = Bitmap.createBitmap(info.width, info.height, config)
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
bitmap.recycle()
throw ImageDecodeException(null, FORMAT_AVIF)
}
return bitmap
}
@RequiresApi(Build.VERSION_CODES.P)
private class DecoderConfigListener(
private val isMutable: Boolean,
) : ImageDecoder.OnHeaderDecodedListener {
override fun onHeaderDecoded(
decoder: ImageDecoder,
info: ImageDecoder.ImageInfo,
source: ImageDecoder.Source
) {
decoder.isMutableRequired = isMutable
}
}
}

View File

@@ -0,0 +1,48 @@
package org.koitharu.kotatsu.core.image
import android.net.Uri
import android.webkit.MimeTypeMap
import coil3.ImageLoader
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.Fetcher
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.toAndroidUri
import kotlinx.coroutines.runInterruptible
import okio.Path.Companion.toPath
import okio.openZip
import org.koitharu.kotatsu.core.util.ext.isZipUri
import coil3.Uri as CoilUri
class CbzFetcher(
private val uri: Uri,
private val options: Options,
) : Fetcher {
override suspend fun fetch() = runInterruptible {
val filePath = uri.schemeSpecificPart.toPath()
val entryName = requireNotNull(uri.fragment)
SourceFetchResult(
source = ImageSource(entryName.toPath(), options.fileSystem.openZip(filePath)),
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(entryName.substringAfterLast('.', "")),
dataSource = DataSource.DISK,
)
}
class Factory : Fetcher.Factory<CoilUri> {
override fun create(
data: CoilUri,
options: Options,
imageLoader: ImageLoader
): Fetcher? {
val androidUri = data.toAndroidUri()
return if (androidUri.isZipUri()) {
CbzFetcher(androidUri, options)
} else {
null
}
}
}
}

View File

@@ -0,0 +1,23 @@
package org.koitharu.kotatsu.core.image
import coil3.intercept.Interceptor
import coil3.network.httpHeaders
import coil3.request.ImageResult
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.util.ext.mangaSourceKey
import org.koitharu.kotatsu.parsers.model.MangaParserSource
class MangaSourceHeaderInterceptor : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val mangaSource = chain.request.extras[mangaSourceKey] as? MangaParserSource ?: return chain.proceed()
val request = chain.request
val newHeaders = request.httpHeaders.newBuilder()
.set(CommonHeaders.MANGA_SOURCE, mangaSource.name)
.build()
val newRequest = request.newBuilder()
.httpHeaders(newHeaders)
.build()
return chain.withRequest(newRequest).proceed()
}
}

View File

@@ -1,39 +1,39 @@
package org.koitharu.kotatsu.core.ui.image
package org.koitharu.kotatsu.core.image
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.Rect
import android.os.Build
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.decode.DecodeResult
import coil.decode.DecodeUtils
import coil.decode.Decoder
import coil.decode.ImageSource
import coil.fetch.SourceResult
import coil.request.Options
import coil.size.Dimension
import coil.size.Scale
import coil.size.Size
import coil.size.isOriginal
import coil.size.pxOrElse
import coil3.Extras
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DecodeResult
import coil3.decode.DecodeUtils
import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult
import coil3.getExtra
import coil3.request.Options
import coil3.request.allowRgb565
import coil3.request.bitmapConfig
import coil3.request.colorSpace
import coil3.request.premultipliedAlpha
import coil3.size.Dimension
import coil3.size.Precision
import coil3.size.Scale
import coil3.size.Size
import coil3.size.isOriginal
import coil3.size.pxOrElse
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlin.math.roundToInt
class RegionBitmapDecoder(
private val source: ImageSource,
private val options: Options,
private val parallelismLock: Semaphore,
) : Decoder {
override suspend fun decode() = parallelismLock.withPermit {
runInterruptible { BitmapFactory.Options().decode() }
}
private fun BitmapFactory.Options.decode(): DecodeResult {
override suspend fun decode(): DecodeResult = runInterruptible {
val regionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BitmapRegionDecoder.newInstance(source.source().inputStream())
} else {
@@ -41,13 +41,14 @@ class RegionBitmapDecoder(
BitmapRegionDecoder.newInstance(source.source().inputStream(), false)
}
checkNotNull(regionDecoder)
val bitmapOptions = BitmapFactory.Options()
try {
val rect = configureScale(regionDecoder.width, regionDecoder.height)
configureConfig()
val bitmap = regionDecoder.decodeRegion(rect, this)
val rect = bitmapOptions.configureScale(regionDecoder.width, regionDecoder.height)
bitmapOptions.configureConfig()
val bitmap = regionDecoder.decodeRegion(rect, bitmapOptions)
bitmap.density = options.context.resources.displayMetrics.densityDpi
return DecodeResult(
drawable = bitmap.toDrawable(options.context.resources),
DecodeResult(
image = bitmap.asImage(),
isSampled = true,
)
} finally {
@@ -55,29 +56,6 @@ class RegionBitmapDecoder(
}
}
private fun BitmapFactory.Options.configureConfig() {
var config = options.config
inMutable = false
if (Build.VERSION.SDK_INT >= 26 && options.colorSpace != null) {
inPreferredColorSpace = options.colorSpace
}
inPremultiplied = options.premultipliedAlpha
// Decode the image as RGB_565 as an optimization if allowed.
if (options.allowRgb565 && config == Bitmap.Config.ARGB_8888 && outMimeType == "image/jpeg") {
config = Bitmap.Config.RGB_565
}
// High color depth images must be decoded as either RGBA_F16 or HARDWARE.
if (Build.VERSION.SDK_INT >= 26 && outConfig == Bitmap.Config.RGBA_F16 && config != Bitmap.Config.HARDWARE) {
config = Bitmap.Config.RGBA_F16
}
inPreferredConfig = config
}
/** Compute and set the scaling properties for [BitmapFactory.Options]. */
private fun BitmapFactory.Options.configureScale(srcWidth: Int, srcHeight: Int): Rect {
val dstWidth = options.size.widthPx(options.scale) { srcWidth }
@@ -91,7 +69,7 @@ class RegionBitmapDecoder(
} else {
Rect(0, 0, (srcHeight / dstRatio).toInt().coerceAtLeast(1), srcHeight)
}
val scroll = options.parameters.value(PARAM_SCROLL) ?: SCROLL_UNDEFINED
val scroll = options.getExtra(regionScrollKey)
if (scroll == SCROLL_UNDEFINED) {
rect.offsetTo(
(srcWidth - rect.width()) / 2,
@@ -123,7 +101,7 @@ class RegionBitmapDecoder(
)
// Only upscale the image if the options require an exact size.
if (options.allowInexactSize) {
if (options.precision == Precision.INEXACT) {
scale = scale.coerceAtMost(1.0)
}
@@ -142,19 +120,36 @@ class RegionBitmapDecoder(
return rect
}
class Factory(
maxParallelism: Int = DEFAULT_MAX_PARALLELISM,
) : Decoder.Factory {
private fun BitmapFactory.Options.configureConfig() {
var config = options.bitmapConfig
@Suppress("NEWER_VERSION_IN_SINCE_KOTLIN")
@SinceKotlin("999.9") // Only public in Java.
constructor() : this()
inMutable = false
private val parallelismLock = Semaphore(maxParallelism)
override fun create(result: SourceResult, options: Options, imageLoader: ImageLoader): Decoder {
return RegionBitmapDecoder(result.source, options, parallelismLock)
if (Build.VERSION.SDK_INT >= 26 && options.colorSpace != null) {
inPreferredColorSpace = options.colorSpace
}
inPremultiplied = options.premultipliedAlpha
// Decode the image as RGB_565 as an optimization if allowed.
if (options.allowRgb565 && config == Bitmap.Config.ARGB_8888 && outMimeType == "image/jpeg") {
config = Bitmap.Config.RGB_565
}
// High color depth images must be decoded as either RGBA_F16 or HARDWARE.
if (Build.VERSION.SDK_INT >= 26 && outConfig == Bitmap.Config.RGBA_F16 && config != Bitmap.Config.HARDWARE) {
config = Bitmap.Config.RGBA_F16
}
inPreferredConfig = config
}
object Factory : Decoder.Factory {
override fun create(
result: SourceFetchResult,
options: Options,
imageLoader: ImageLoader
): Decoder = RegionBitmapDecoder(result.source, options)
override fun equals(other: Any?) = other is Factory
@@ -163,9 +158,8 @@ class RegionBitmapDecoder(
companion object {
const val PARAM_SCROLL = "scroll"
const val SCROLL_UNDEFINED = -1
private const val DEFAULT_MAX_PARALLELISM = 4
val regionScrollKey = Extras.Key(SCROLL_UNDEFINED)
private inline fun Size.widthPx(scale: Scale, original: () -> Int): Int {
return if (isOriginal) original() else width.toPx(scale)

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.core.io
import java.io.OutputStream
import java.util.Objects
class NullOutputStream : OutputStream() {
override fun write(b: Int) = Unit
override fun write(b: ByteArray, off: Int, len: Int) {
Objects.checkFromIndexSize(off, len, b.size)
}
}

View File

@@ -1,148 +0,0 @@
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.NonCancellable
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.core.util.ext.subdir
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.File
import java.io.FileOutputStream
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
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 dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withLocale(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(dateTimeFormatter.format(LocalDateTime.now()))
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()
}
@WorkerThread
fun flushBlocking() {
if (!isEnabled) {
return
}
runBlockingSafe { flushJob?.cancelAndJoin() }
runBlockingSafe { 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() = withContext(NonCancellable) {
mutex.withLock {
if (buffer.isEmpty()) {
return@withContext
}
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()
}
private inline fun runBlockingSafe(crossinline block: suspend () -> Unit) = try {
runBlocking(NonCancellable) { block() }
} catch (_: InterruptedException) {
}
}

View File

@@ -1,11 +0,0 @@
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

@@ -1,40 +0,0 @@
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

@@ -4,6 +4,7 @@ import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.model.SortOrder
@Deprecated("")
enum class GenericSortOrder(
@StringRes val titleResId: Int,
val ascending: SortOrder,

View File

@@ -1,18 +1,23 @@
package org.koitharu.kotatsu.core.model
import android.net.Uri
import android.text.SpannableStringBuilder
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.collection.MutableObjectIntMap
import androidx.core.os.LocaleListCompat
import androidx.core.text.buildSpannedString
import androidx.core.text.strikeThrough
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.iterator
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Demographic
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.formatSimple
import org.koitharu.kotatsu.parsers.util.findById
import org.koitharu.kotatsu.parsers.util.mapToSet
import com.google.android.material.R as materialR
@@ -24,8 +29,6 @@ fun Collection<Manga>.distinctById() = distinctBy { it.id }
@JvmName("chaptersIds")
fun Collection<MangaChapter>.ids() = mapToSet { it.id }
fun Collection<MangaChapter>.findById(id: Long) = find { x -> x.id == id }
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) {
return size
@@ -68,9 +71,16 @@ val ContentRating.titleResId: Int
ContentRating.ADULT -> R.string.rating_adult
}
fun Manga.findChapter(id: Long): MangaChapter? {
return chapters?.findById(id)
}
@get:StringRes
val Demographic.titleResId: Int
get() = when (this) {
Demographic.SHOUNEN -> R.string.demographic_shounen
Demographic.SHOUJO -> R.string.demographic_shoujo
Demographic.SEINEN -> R.string.demographic_seinen
Demographic.JOSEI -> R.string.demographic_josei
Demographic.KODOMO -> R.string.demographic_kodomo
Demographic.NONE -> R.string.none
}
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
val ch = chapters
@@ -110,6 +120,9 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
val Manga.isLocal: Boolean
get() = source == LocalMangaSource
val Manga.isBroken: Boolean
get() = source == UnknownMangaSource
val Manga.appUrl: Uri
get() = Uri.parse("https://kotatsu.app/manga").buildUpon()
.appendQueryParameter("source", source.name)
@@ -117,12 +130,6 @@ val Manga.appUrl: Uri
.appendQueryParameter("url", url)
.build()
fun MangaChapter.formatNumber(): String? = if (number > 0f) {
number.formatSimple()
} else {
null
}
fun Manga.chaptersCount(): Int {
if (chapters.isNullOrEmpty()) {
return 0
@@ -138,3 +145,26 @@ fun Manga.chaptersCount(): Int {
}
return max
}
fun MangaListFilter.getSummary() = buildSpannedString {
if (!query.isNullOrEmpty()) {
append(query)
if (tags.isNotEmpty() || tagsExclude.isNotEmpty()) {
append(' ')
append('(')
appendTagsSummary(this@getSummary)
append(')')
}
} else {
appendTagsSummary(this@getSummary)
}
}
private fun SpannableStringBuilder.appendTagsSummary(filter: MangaListFilter) {
filter.tags.joinTo(this) { it.title }
if (filter.tagsExclude.isNotEmpty()) {
strikeThrough {
filter.tagsExclude.joinTo(this) { it.title }
}
}
}

View File

@@ -12,4 +12,5 @@ data class MangaHistory(
val page: Int,
val scroll: Int,
val percent: Float,
val chaptersCount: Int,
) : Parcelable

View File

@@ -43,6 +43,8 @@ fun MangaSource(name: String?): MangaSource {
return UnknownMangaSource
}
fun Collection<String>.toMangaSources() = map(::MangaSource)
fun MangaSource.isNsfw(): Boolean = when (this) {
is MangaSourceInfo -> mangaSource.isNsfw()
is MangaParserSource -> contentType == ContentType.HENTAI
@@ -56,13 +58,26 @@ val ContentType.titleResId
ContentType.HENTAI -> R.string.content_type_hentai
ContentType.COMICS -> R.string.content_type_comics
ContentType.OTHER -> R.string.content_type_other
ContentType.MANHWA -> R.string.content_type_manhwa
ContentType.MANHUA -> R.string.content_type_manhua
ContentType.NOVEL -> R.string.content_type_novel
ContentType.ONE_SHOT -> R.string.content_type_one_shot
ContentType.DOUJINSHI -> R.string.content_type_doujinshi
ContentType.IMAGE_SET -> R.string.content_type_image_set
ContentType.ARTIST_CG -> R.string.content_type_artist_cg
ContentType.GAME_CG -> R.string.content_type_game_cg
}
fun MangaSource.getSummary(context: Context): String? = when (this) {
is MangaSourceInfo -> mangaSource.getSummary(context)
tailrec fun MangaSource.unwrap(): MangaSource = if (this is MangaSourceInfo) {
mangaSource.unwrap()
} else {
this
}
fun MangaSource.getSummary(context: Context): String? = when (val source = unwrap()) {
is MangaParserSource -> {
val type = context.getString(contentType.titleResId)
val locale = locale.toLocale().getDisplayName(context)
val type = context.getString(source.contentType.titleResId)
val locale = source.locale.toLocale().getDisplayName(context)
context.getString(R.string.source_summary_pattern, type, locale)
}
@@ -71,11 +86,10 @@ fun MangaSource.getSummary(context: Context): String? = when (this) {
else -> null
}
fun MangaSource.getTitle(context: Context): String = when (this) {
is MangaSourceInfo -> mangaSource.getTitle(context)
is MangaParserSource -> title
fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()) {
is MangaParserSource -> source.title
LocalMangaSource -> context.getString(R.string.local_storage)
is ExternalMangaSource -> resolveName(context)
is ExternalMangaSource -> source.resolveName(context)
else -> context.getString(R.string.unknown)
}

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import androidx.core.os.ParcelCompat
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.model.MangaSource
@@ -13,6 +12,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
@Parcelize
data class ParcelableManga(
val manga: Manga,
private val withDescription: Boolean = true,
) : Parcelable {
companion object : Parceler<ParcelableManga> {
@@ -24,10 +24,10 @@ data class ParcelableManga(
parcel.writeString(url)
parcel.writeString(publicUrl)
parcel.writeFloat(rating)
ParcelCompat.writeBoolean(parcel, isNsfw)
parcel.writeSerializable(contentRating)
parcel.writeString(coverUrl)
parcel.writeString(largeCoverUrl)
parcel.writeString(description)
parcel.writeString(description.takeIf { withDescription })
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
parcel.writeSerializable(state)
parcel.writeString(author)
@@ -42,8 +42,8 @@ data class ParcelableManga(
url = requireNotNull(parcel.readString()),
publicUrl = requireNotNull(parcel.readString()),
rating = parcel.readFloat(),
isNsfw = ParcelCompat.readBoolean(parcel),
coverUrl = requireNotNull(parcel.readString()),
contentRating = parcel.readSerializableCompat(),
coverUrl = parcel.readString(),
largeCoverUrl = parcel.readString(),
description = parcel.readString(),
tags = requireNotNull(parcel.readParcelableCompat<ParcelableMangaTags>()).tags,
@@ -52,6 +52,7 @@ data class ParcelableManga(
chapters = null,
source = MangaSource(parcel.readString()),
),
withDescription = true,
)
}
}

View File

@@ -0,0 +1,53 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.util.ext.readEnumSet
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.core.util.ext.writeEnumSet
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Demographic
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaState
object MangaListFilterParceler : Parceler<MangaListFilter> {
override fun MangaListFilter.write(parcel: Parcel, flags: Int) {
parcel.writeString(query)
parcel.writeParcelable(ParcelableMangaTags(tags), 0)
parcel.writeParcelable(ParcelableMangaTags(tagsExclude), 0)
parcel.writeSerializable(locale)
parcel.writeSerializable(originalLocale)
parcel.writeEnumSet(states)
parcel.writeEnumSet(contentRating)
parcel.writeEnumSet(types)
parcel.writeEnumSet(demographics)
parcel.writeInt(year)
parcel.writeInt(yearFrom)
parcel.writeInt(yearTo)
}
override fun create(parcel: Parcel) = MangaListFilter(
query = parcel.readString(),
tags = parcel.readParcelableCompat<ParcelableMangaTags>()?.tags.orEmpty(),
tagsExclude = parcel.readParcelableCompat<ParcelableMangaTags>()?.tags.orEmpty(),
locale = parcel.readSerializableCompat(),
originalLocale = parcel.readSerializableCompat(),
states = parcel.readEnumSet<MangaState>().orEmpty(),
contentRating = parcel.readEnumSet<ContentRating>().orEmpty(),
types = parcel.readEnumSet<ContentType>().orEmpty(),
demographics = parcel.readEnumSet<Demographic>().orEmpty(),
year = parcel.readInt(),
yearFrom = parcel.readInt(),
yearTo = parcel.readInt(),
)
}
@Parcelize
@TypeParceler<MangaListFilter, MangaListFilterParceler>
data class ParcelableMangaListFilter(val filter: MangaListFilter) : Parcelable

View File

@@ -35,7 +35,7 @@ class AppProxySelector(
if (type == Proxy.Type.DIRECT) {
return Proxy.NO_PROXY
}
if (address.isNullOrEmpty() || port == 0) {
if (address.isNullOrEmpty() || port < 0 || port > 0xFFFF) {
throw ProxyConfigException()
}
cachedProxy?.let {

View File

@@ -2,41 +2,43 @@ package org.koitharu.kotatsu.core.network
import okhttp3.Interceptor
import okhttp3.Response
import okhttp3.internal.closeQuietly
import org.jsoup.Jsoup
import okio.IOException
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.net.HttpURLConnection.HTTP_FORBIDDEN
import java.net.HttpURLConnection.HTTP_UNAVAILABLE
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
class CloudFlareInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
val content = response.body?.let { response.peekBody(Long.MAX_VALUE) }?.byteStream()?.use {
Jsoup.parse(it, Charsets.UTF_8.name(), response.request.url.toString())
} ?: return response
val hasCaptcha = content.getElementById("challenge-error-title") != null
val isBlocked = content.selectFirst("h2[data-translate=\"blocked_why_headline\"]") != null
if (hasCaptcha || isBlocked) {
val request = response.request
response.closeQuietly()
if (isBlocked) {
throw CloudFlareBlockedException(
url = request.url.toString(),
source = request.tag(MangaSource::class.java),
)
} else {
throw CloudFlareProtectedException(
url = request.url.toString(),
source = request.tag(MangaSource::class.java),
headers = request.headers,
)
}
}
val request = chain.request()
val response = chain.proceed(request)
return when (CloudFlareHelper.checkResponseForProtection(response)) {
CloudFlareHelper.PROTECTION_BLOCKED -> response.closeThrowing(
CloudFlareBlockedException(
url = request.url.toString(),
source = request.tag(MangaSource::class.java),
),
)
CloudFlareHelper.PROTECTION_CAPTCHA -> response.closeThrowing(
CloudFlareProtectedException(
url = request.url.toString(),
source = request.tag(MangaSource::class.java),
headers = request.headers,
),
)
else -> response
}
return response
}
private fun Response.closeThrowing(error: IOException): Nothing {
try {
close()
} catch (e: Exception) {
error.addSuppressed(e)
}
throw error
}
}

View File

@@ -16,6 +16,7 @@ object CommonHeaders {
const val CACHE_CONTROL = "Cache-Control"
const val PROXY_AUTHORIZATION = "Proxy-Authorization"
const val RETRY_AFTER = "Retry-After"
const val MANGA_SOURCE = "X-Manga-Source"
val CACHE_CONTROL_NO_STORE: CacheControl
get() = CacheControl.Builder().noStore().build()

View File

@@ -9,10 +9,12 @@ import okhttp3.Request
import okhttp3.Response
import okio.IOException
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mergeWith
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -29,15 +31,17 @@ class CommonHeadersInterceptor @Inject constructor(
override fun intercept(chain: Chain): Response {
val request = chain.request()
val source = request.tag(MangaSource::class.java)
val repository = if (source != null) {
?: request.headers[CommonHeaders.MANGA_SOURCE]?.let { MangaSource(it) }
val repository = if (source is MangaParserSource) {
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
} else {
if (BuildConfig.DEBUG) {
if (BuildConfig.DEBUG && source == null) {
Log.w("Http", "Request without source tag: ${request.url}")
}
null
}
val headersBuilder = request.headers.newBuilder()
.removeAll(CommonHeaders.MANGA_SOURCE)
repository?.getRequestHeaders()?.let {
headersBuilder.mergeWith(it, replaceExisting = false)
}

View File

@@ -85,7 +85,7 @@ class DoHManager(
).build()
DoHProvider.ZERO_MS -> DnsOverHttps.Builder().client(bootstrapClient)
.url("https://2ca4h4crra.cloudflare-gateway.com/dns-query".toHttpUrl())
.url("https://0ms.dev/dns-query".toHttpUrl())
.resolvePublicAddresses(true)
.build()
}

View File

@@ -1,19 +1,26 @@
package org.koitharu.kotatsu.core.network
import okhttp3.Interceptor
import okhttp3.MultipartBody
import okhttp3.Response
import okio.IOException
import org.koitharu.kotatsu.core.exceptions.WrapperIOException
import org.koitharu.kotatsu.core.network.CommonHeaders.CONTENT_ENCODING
class GZipInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val newRequest = chain.request().newBuilder()
newRequest.addHeader(CONTENT_ENCODING, "gzip")
return try {
override fun intercept(chain: Interceptor.Chain): Response = try {
val request = chain.request()
if (request.body is MultipartBody) {
chain.proceed(request)
} else {
val newRequest = request.newBuilder()
newRequest.addHeader(CONTENT_ENCODING, "gzip")
chain.proceed(newRequest.build())
} catch (e: NullPointerException) {
throw IOException(e)
}
} catch (e: IOException) {
throw e
} catch (e: Exception) {
throw WrapperIOException(e)
}
}

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core.network
import android.content.Context
import android.util.AndroidRuntimeException
import dagger.Binds
import dagger.Module
import dagger.Provides
@@ -19,6 +18,7 @@ import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.imageproxy.RealImageProxyInterceptor
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.local.data.LocalStorageManager
import java.util.concurrent.TimeUnit
import javax.inject.Provider
@@ -40,9 +40,10 @@ interface NetworkModule {
@Singleton
fun provideCookieJar(
@ApplicationContext context: Context
): MutableCookieJar = try {
): MutableCookieJar = runCatching {
AndroidCookieJar()
} catch (e: AndroidRuntimeException) {
}.getOrElse { e ->
e.printStackTraceDebug()
// WebView is not available
PreferencesCookieJar(context)
}
@@ -73,7 +74,7 @@ interface NetworkModule {
if (settings.isSSLBypassEnabled) {
disableCertificateVerification()
} else {
installExtraCertsificates(contextProvider.get())
installExtraCertificates(contextProvider.get())
}
cache(cache)
addInterceptor(GZipInterceptor())

View File

@@ -35,7 +35,7 @@ fun OkHttpClient.Builder.disableCertificateVerification() = also { builder ->
}
}
fun OkHttpClient.Builder.installExtraCertsificates(context: Context) = also { builder ->
fun OkHttpClient.Builder.installExtraCertificates(context: Context) = also { builder ->
val certificatesBuilder = HandshakeCertificates.Builder()
.addPlatformTrustedCertificates()
val assets = context.assets.list("").orEmpty()

View File

@@ -2,12 +2,12 @@ package org.koitharu.kotatsu.core.network.imageproxy
import android.util.Log
import androidx.collection.ArraySet
import coil.intercept.Interceptor
import coil.network.HttpException
import coil.request.ErrorResult
import coil.request.ImageRequest
import coil.request.ImageResult
import coil.request.SuccessResult
import coil3.intercept.Interceptor
import coil3.network.HttpException
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.SuccessResult
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
@@ -35,14 +35,14 @@ abstract class BaseImageProxyInterceptor : ImageProxyInterceptor {
else -> null
}
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
return chain.proceed(request)
return chain.proceed()
}
val newRequest = onInterceptImageRequest(request, url)
return when (val result = chain.proceed(newRequest)) {
return when (val result = chain.withRequest(newRequest).proceed()) {
is SuccessResult -> result
is ErrorResult -> {
logDebug(result.throwable, newRequest.data)
chain.proceed(request).also {
chain.proceed().also {
if (it is SuccessResult && result.throwable.isBlockedByServer()) {
blacklist.add(url.host)
}

View File

@@ -1,6 +1,6 @@
package org.koitharu.kotatsu.core.network.imageproxy
import coil.intercept.Interceptor
import coil3.intercept.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response

View File

@@ -1,7 +1,7 @@
package org.koitharu.kotatsu.core.network.imageproxy
import coil.intercept.Interceptor
import coil.request.ImageResult
import coil3.intercept.Interceptor
import coil3.request.ImageResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.plus
import okhttp3.OkHttpClient
@@ -26,7 +26,7 @@ class RealImageProxyInterceptor @Inject constructor(
)
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
return delegate.value?.intercept(chain) ?: chain.proceed(chain.request)
return delegate.value?.intercept(chain) ?: chain.proceed()
}
override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {

View File

@@ -1,8 +1,8 @@
package org.koitharu.kotatsu.core.network.imageproxy
import coil.request.ImageRequest
import coil.size.Dimension
import coil.size.isOriginal
import coil3.request.ImageRequest
import coil3.size.Dimension
import coil3.size.isOriginal
import okhttp3.HttpUrl
import okhttp3.Request

View File

@@ -1,6 +1,6 @@
package org.koitharu.kotatsu.core.network.imageproxy
import coil.request.ImageRequest
import coil3.request.ImageRequest
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request

View File

@@ -10,10 +10,11 @@ import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.room.InvalidationTracker
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import coil.size.Size
import coil3.ImageLoader
import coil3.request.ImageRequest
import coil3.request.transformations
import coil3.size.Scale
import coil3.size.Size
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -27,9 +28,9 @@ import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -138,7 +139,7 @@ class AppShortcutManager @Inject constructor(
ImageRequest.Builder(context)
.data(manga.coverUrl)
.size(iconSize)
.source(manga.source)
.mangaSourceExtra(manga.source)
.scale(Scale.FILL)
.transformations(ThumbnailTransformation())
.build(),
@@ -180,7 +181,7 @@ class AppShortcutManager @Inject constructor(
.setLongLabel(title)
.setIcon(icon)
.setLongLived(true)
.setIntent(MangaListActivity.newIntent(context, source))
.setIntent(MangaListActivity.newIntent(context, source, null))
.build()
}
}

View File

@@ -6,6 +6,7 @@ import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import coil3.network.ConnectivityChecker
import kotlinx.coroutines.flow.first
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.MediatorStateFlow
@@ -13,13 +14,17 @@ import org.koitharu.kotatsu.core.util.MediatorStateFlow
class NetworkState(
private val connectivityManager: ConnectivityManager,
private val settings: AppSettings,
) : MediatorStateFlow<Boolean>(connectivityManager.isOnline(settings)) {
) : MediatorStateFlow<Boolean>(connectivityManager.isOnline(settings)), ConnectivityChecker {
private val callback = NetworkCallbackImpl()
override val value: Boolean
get() = connectivityManager.isOnline(settings)
override fun isOnline(): Boolean {
return connectivityManager.isOnline(settings)
}
@Synchronized
override fun onActive() {
invalidate()

View File

@@ -9,7 +9,7 @@ import android.graphics.Rect as AndroidRect
class BitmapWrapper private constructor(
private val androidBitmap: AndroidBitmap,
) : Bitmap {
) : Bitmap, AutoCloseable {
private val canvas by lazy { Canvas(androidBitmap) } // is not always used, so initialized lazily
@@ -24,17 +24,21 @@ class BitmapWrapper private constructor(
canvas.drawBitmap(androidSourceBitmap, src.toAndroidRect(), dst.toAndroidRect(), null)
}
override fun close() {
androidBitmap.recycle()
}
fun compressTo(output: OutputStream) {
androidBitmap.compress(AndroidBitmap.CompressFormat.PNG, 100, output)
}
companion object {
fun create(width: Int, height: Int): Bitmap = BitmapWrapper(
fun create(width: Int, height: Int) = BitmapWrapper(
AndroidBitmap.createBitmap(width, height, AndroidBitmap.Config.ARGB_8888),
)
fun create(bitmap: AndroidBitmap): Bitmap = BitmapWrapper(
fun create(bitmap: AndroidBitmap) = BitmapWrapper(
if (bitmap.isMutable) bitmap else bitmap.copy(AndroidBitmap.Config.ARGB_8888, true),
)

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.parser
import android.util.Log
import androidx.collection.MutableLongSet
import coil.request.CachePolicy
import coil3.request.CachePolicy
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers

View File

@@ -7,9 +7,10 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet
@@ -24,14 +25,17 @@ class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParse
override val availableSortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)
override val filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities()
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> = stub(null)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
override suspend fun getAvailableTags(): Set<MangaTag> = stub(null)
private fun stub(manga: Manga?): Nothing {
throw UnsupportedSourceException("Usage of Dummy parser", manga)
}

View File

@@ -1,37 +1,29 @@
package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet
import java.util.Locale
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)
override val states: Set<MangaState>
get() = emptySet()
override val contentRatings: Set<ContentRating>
get() = emptySet()
override var defaultSortOrder: SortOrder
get() = SortOrder.NEWEST
set(value) = Unit
override val isMultipleTagsSupported: Boolean
get() = false
override val isTagsExclusionSupported: Boolean
get() = false
override val isSearchSupported: Boolean
get() = false
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
override val filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities()
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> = stub(null)
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
@@ -39,9 +31,7 @@ class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
override suspend fun getPageUrl(page: MangaPage): String = stub(null)
override suspend fun getTags(): Set<MangaTag> = stub(null)
override suspend fun getLocales(): Set<Locale> = stub(null)
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
override suspend fun getRelated(seed: Manga): List<Manga> = stub(seed)

View File

@@ -52,6 +52,10 @@ class MangaDataRepository @Inject constructor(
}
}
suspend fun resetColorFilters() {
db.getPreferencesDao().resetColorFilters()
}
suspend fun getReaderMode(mangaId: Long): ReaderMode? {
return db.getPreferencesDao().find(mangaId)?.let { ReaderMode.valueOf(it.mode) }
}

View File

@@ -1,13 +1,13 @@
package org.koitharu.kotatsu.core.parser
import android.net.Uri
import coil.request.CachePolicy
import coil3.request.CachePolicy
import dagger.Reusable
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
@@ -15,21 +15,20 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.almostEquals
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.toRelativeUrl
import javax.inject.Inject
@Reusable
class MangaLinkResolver @Inject constructor(
private val repositoryFactory: MangaRepository.Factory,
private val sourcesRepository: MangaSourcesRepository,
private val dataRepository: MangaDataRepository,
private val context: MangaLoaderContext,
) {
suspend fun resolve(uri: Uri): Manga {
return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") {
resolveAppLink(uri)
} else {
resolveExternalLink(uri)
resolveExternalLink(uri.toString())
} ?: throw NotFoundException("Cannot resolve link", uri.toString())
}
@@ -45,23 +44,16 @@ class MangaLinkResolver @Inject constructor(
)
}
private suspend fun resolveExternalLink(uri: Uri): Manga? {
dataRepository.findMangaByPublicUrl(uri.toString())?.let {
private suspend fun resolveExternalLink(uri: String): Manga? {
dataRepository.findMangaByPublicUrl(uri)?.let {
return it
}
val host = uri.host ?: return null
val repo = sourcesRepository.allMangaSources.asSequence()
.map { source ->
repositoryFactory.create(source) as ParserMangaRepository
}.find { repo ->
host in repo.domains
} ?: return null
return repo.findExact(uri.toString().toRelativeUrl(host), null)
return context.newLinkResolver(uri).getManga()
}
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
if (!title.isNullOrEmpty()) {
val list = getList(0, MangaListFilter.Search(title))
val list = getList(0, null, MangaListFilter(query = title))
if (url != null) {
list.find { it.url == url }?.let {
return it
@@ -80,17 +72,15 @@ class MangaLinkResolver @Inject constructor(
}.ifNullOrEmpty {
seed.author
} ?: return@runCatchingCancellable null
val seedList = getList(0, MangaListFilter.Search(seedTitle))
val seedList = getList(0, null, MangaListFilter(query = seedTitle))
seedList.first { x -> x.url == url }
}.getOrThrow()
}
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
return if (this is ParserMangaRepository) {
getDetails(manga, CachePolicy.READ_ONLY)
} else {
getDetails(manga)
}
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga = if (this is CachingMangaRepository) {
getDetails(manga, CachePolicy.READ_ONLY)
} else {
getDetails(manga)
}
private fun getSeedManga(source: MangaSource, url: String, title: String?) = Manga(

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.core.parser
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.BitmapFactory
import android.util.Base64
import android.webkit.WebView
import androidx.annotation.MainThread
@@ -16,19 +15,21 @@ import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.ResponseBody.Companion.asResponseBody
import okio.Buffer
import org.koitharu.kotatsu.core.image.BitmapDecoderCompat
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.requireBody
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
import org.koitharu.kotatsu.core.util.ext.toList
import org.koitharu.kotatsu.core.util.ext.use
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.UserAgents
import org.koitharu.kotatsu.parsers.util.map
import java.lang.ref.WeakReference
import java.util.Locale
import javax.inject.Inject
@@ -76,32 +77,23 @@ class MangaLoaderContextImpl @Inject constructor(
}
override fun redrawImageResponse(response: Response, redraw: (image: Bitmap) -> Bitmap): Response {
val image = response.requireBody().byteStream()
val opts = BitmapFactory.Options()
opts.inMutable = true
val bitmap = BitmapFactory.decodeStream(image, null, opts) ?: error("Cannot decode bitmap")
val result = redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper
val body = Buffer().also {
result.compressTo(it.outputStream())
}.asResponseBody("image/jpeg".toMediaType())
return response.newBuilder()
.body(body)
.build()
return response.map { body ->
BitmapDecoderCompat.decode(body.byteStream(), body.contentType(), isMutable = true).use { bitmap ->
(redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper).use { result ->
Buffer().also {
result.compressTo(it.outputStream())
}.asResponseBody("image/jpeg".toMediaType())
}
}
}
}
override fun createBitmap(width: Int, height: Int): Bitmap {
return BitmapWrapper.create(width, height)
}
override fun createBitmap(width: Int, height: Int): Bitmap = BitmapWrapper.create(width, height)
@MainThread
private fun obtainWebView(): WebView {
return webViewCached?.get() ?: WebView(androidContext).also {
it.configureForParser(null)
webViewCached = WeakReference(it)
}
private fun obtainWebView(): WebView = webViewCached?.get() ?: WebView(androidContext).also {
it.configureForParser(null)
webViewCached = WeakReference(it)
}
private fun obtainWebViewUserAgent(): String {

View File

@@ -13,18 +13,16 @@ import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.lang.ref.WeakReference
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.collections.set
@@ -35,19 +33,11 @@ interface MangaRepository {
val sortOrders: Set<SortOrder>
val states: Set<MangaState>
val contentRatings: Set<ContentRating>
var defaultSortOrder: SortOrder
val isMultipleTagsSupported: Boolean
val filterCapabilities: MangaListFilterCapabilities
val isTagsExclusionSupported: Boolean
val isSearchSupported: Boolean
suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga>
suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga>
suspend fun getDetails(manga: Manga): Manga
@@ -55,14 +45,12 @@ interface MangaRepository {
suspend fun getPageUrl(page: MangaPage): String
suspend fun getTags(): Set<MangaTag>
suspend fun getLocales(): Set<Locale>
suspend fun getFilterOptions(): MangaListFilterOptions
suspend fun getRelated(seed: Manga): List<Manga>
suspend fun find(manga: Manga): Manga? {
val list = getList(0, MangaListFilter.Search(manga.title))
val list = getList(0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title))
return list.find { x -> x.id == manga.id }
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.parser
import kotlinx.coroutines.Dispatchers
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.core.cache.MemoryContentCache
@@ -8,19 +9,18 @@ import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Favicons
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Locale
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
class ParserMangaRepository(
private val parser: MangaParser,
@@ -28,17 +28,20 @@ class ParserMangaRepository(
cache: MemoryContentCache,
) : CachingMangaRepository(cache), Interceptor {
private val filterOptionsLazy = suspendLazy(Dispatchers.Default) {
mirrorSwitchInterceptor.withMirrorSwitching {
parser.getFilterOptions()
}
}
override val source: MangaParserSource
get() = parser.source
override val sortOrders: Set<SortOrder>
get() = parser.availableSortOrders
override val states: Set<MangaState>
get() = parser.availableStates
override val contentRatings: Set<ContentRating>
get() = parser.availableContentRating
override val filterCapabilities: MangaListFilterCapabilities
get() = parser.filterCapabilities
override var defaultSortOrder: SortOrder
get() = getConfig().defaultSortOrder ?: sortOrders.first()
@@ -46,15 +49,6 @@ class ParserMangaRepository(
getConfig().defaultSortOrder = value
}
override val isMultipleTagsSupported: Boolean
get() = parser.isMultipleTagsSupported
override val isSearchSupported: Boolean
get() = parser.isSearchSupported
override val isTagsExclusionSupported: Boolean
get() = parser.isTagsExclusionSupported
var domain: String
get() = parser.domain
set(value) {
@@ -72,9 +66,9 @@ class ParserMangaRepository(
}
}
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> {
return mirrorSwitchInterceptor.withMirrorSwitching {
parser.getList(offset, filter)
parser.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY)
}
}
@@ -85,16 +79,12 @@ class ParserMangaRepository(
}
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPageUrl(page)
parser.getPageUrl(page).also { result ->
check(result.isNotEmpty()) { "Page url is empty" }
}
}
override suspend fun getTags(): Set<MangaTag> = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getAvailableTags()
}
override suspend fun getLocales(): Set<Locale> {
return parser.getAvailableLocales()
}
override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptionsLazy.get()
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getFavicons()

View File

@@ -6,19 +6,18 @@ import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import java.util.EnumSet
import java.util.Locale
class ExternalMangaRepository(
private val contentResolver: ContentResolver,
contentResolver: ContentResolver,
override val source: ExternalMangaSource,
cache: MemoryContentCache,
) : CachingMangaRepository(cache) {
@@ -33,31 +32,23 @@ class ExternalMangaRepository(
}.getOrNull()
}
private val filterOptions = suspendLazy(initializer = contentSource::getListFilterOptions)
override val sortOrders: Set<SortOrder>
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL)
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.POPULARITY)
override val states: Set<MangaState>
get() = capabilities?.availableStates.orEmpty()
override val contentRatings: Set<ContentRating>
get() = capabilities?.availableContentRating.orEmpty()
override val filterCapabilities: MangaListFilterCapabilities
get() = capabilities?.listFilterCapabilities ?: MangaListFilterCapabilities()
override var defaultSortOrder: SortOrder
get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL
set(value) = Unit
get() = capabilities?.availableSortOrders?.firstOrNull() ?: SortOrder.ALPHABETICAL
set(_) = Unit
override val isMultipleTagsSupported: Boolean
get() = capabilities?.isMultipleTagsSupported ?: true
override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptions.get()
override val isTagsExclusionSupported: Boolean
get() = capabilities?.isTagsExclusionSupported ?: false
override val isSearchSupported: Boolean
get() = capabilities?.isSearchSupported ?: true
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> =
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> =
runInterruptible(Dispatchers.IO) {
contentSource.getList(offset, filter)
contentSource.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY)
}
override suspend fun getDetailsImpl(manga: Manga): Manga = runInterruptible(Dispatchers.IO) {
@@ -68,13 +59,9 @@ class ExternalMangaRepository(
contentSource.getPages(chapter)
}
override suspend fun getPageUrl(page: MangaPage): String = page.url // TODO
override suspend fun getTags(): Set<MangaTag> = runInterruptible(Dispatchers.IO) {
contentSource.getTags()
override suspend fun getPageUrl(page: MangaPage): String = runInterruptible(Dispatchers.IO) {
contentSource.getPageUrl(page.url)
}
override suspend fun getLocales(): Set<Locale> = emptySet() // TODO
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO
}

View File

@@ -8,12 +8,15 @@ import androidx.core.net.toUri
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.toLocale
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Demographic
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
@@ -31,25 +34,29 @@ class ExternalPluginContentSource(
@Blocking
@WorkerThread
fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
fun getListFilterOptions() = MangaListFilterOptions(
availableTags = fetchTags(),
availableStates = fetchEnumSet(MangaState::class.java, "filter/states"),
availableContentRating = fetchEnumSet(ContentRating::class.java, "filter/content_ratings"),
availableContentTypes = fetchEnumSet(ContentType::class.java, "filter/content_types"),
availableDemographics = fetchEnumSet(Demographic::class.java, "filter/demographics"),
availableLocales = fetchLocales(),
)
@Blocking
@WorkerThread
fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
val uri = "content://${source.authority}/manga".toUri().buildUpon()
uri.appendQueryParameter("offset", offset.toString())
when (filter) {
is MangaListFilter.Advanced -> {
filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") }
filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") }
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
}
is MangaListFilter.Search -> {
uri.appendQueryParameter("query", filter.query)
}
null -> Unit
filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") }
filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") }
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
if (!filter.query.isNullOrEmpty()) {
uri.appendQueryParameter("query", filter.query)
}
return contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name)
return contentResolver.query(uri.build(), null, null, null, order.name)
.safe()
.use { cursor ->
val result = ArrayList<Manga>(cursor.count)
@@ -75,7 +82,7 @@ class ExternalPluginContentSource(
publicUrl = details.publicUrl.ifEmpty { manga.publicUrl },
rating = maxOf(details.rating, manga.rating),
isNsfw = details.isNsfw,
coverUrl = details.coverUrl.ifEmpty { manga.coverUrl },
coverUrl = details.coverUrl.ifNullOrEmpty { manga.coverUrl },
tags = details.tags + manga.tags,
state = details.state ?: manga.state,
author = details.author.ifNullOrEmpty { manga.author },
@@ -113,8 +120,8 @@ class ExternalPluginContentSource(
@Blocking
@WorkerThread
fun getTags(): Set<MangaTag> {
val uri = "content://${source.authority}/tags".toUri()
private fun fetchTags(): Set<MangaTag> {
val uri = "content://${source.authority}/filter/tags".toUri()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
@@ -132,6 +139,40 @@ class ExternalPluginContentSource(
}
}
@Blocking
@WorkerThread
fun getPageUrl(url: String): String {
val uri = "content://${source.authority}/manga/pages/0".toUri().buildUpon()
.appendQueryParameter("url", url)
.build()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
if (cursor.moveToFirst()) {
cursor.getString(COLUMN_VALUE)
} else {
url
}
}
}
@Blocking
@WorkerThread
private fun fetchLocales(): Set<Locale> {
val uri = "content://${source.authority}/filter/locales".toUri()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
val result = ArraySet<Locale>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += Locale(cursor.getString(COLUMN_NAME))
} while (cursor.moveToNext())
}
result
}
}
fun getCapabilities(): MangaSourceCapabilities? {
val uri = "content://${source.authority}/capabilities".toUri()
return contentResolver.query(uri, null, null, null, null)
@@ -144,26 +185,18 @@ class ExternalPluginContentSource(
?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) {
SortOrder.entries.find(it)
}.orEmpty(),
availableStates = cursor.getStringOrNull(COLUMN_STATES)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(MangaState::class.java)) {
MangaState.entries.find(it)
}.orEmpty(),
availableContentRating = cursor.getStringOrNull(COLUMN_CONTENT_RATING)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(ContentRating::class.java)) {
ContentRating.entries.find(it)
}.orEmpty(),
isMultipleTagsSupported = cursor.getBooleanOrDefault(COLUMN_MULTIPLE_TAGS_SUPPORTED, true),
isTagsExclusionSupported = cursor.getBooleanOrDefault(COLUMN_TAGS_EXCLUSION_SUPPORTED, false),
isSearchSupported = cursor.getBooleanOrDefault(COLUMN_SEARCH_SUPPORTED, true),
contentType = cursor.getStringOrNull(COLUMN_CONTENT_TYPE)?.let {
ContentType.entries.find(it)
} ?: ContentType.OTHER,
defaultSortOrder = cursor.getStringOrNull(COLUMN_DEFAULT_SORT_ORDER)?.let {
SortOrder.entries.find(it)
} ?: SortOrder.ALPHABETICAL,
sourceLocale = cursor.getStringOrNull(COLUMN_LOCALE)?.toLocale() ?: Locale.ROOT,
listFilterCapabilities = MangaListFilterCapabilities(
isMultipleTagsSupported = cursor.getBooleanOrDefault(COLUMN_MULTIPLE_TAGS, false),
isTagsExclusionSupported = cursor.getBooleanOrDefault(COLUMN_TAGS_EXCLUSION, false),
isSearchSupported = cursor.getBooleanOrDefault(COLUMN_SEARCH, false),
isSearchWithFiltersSupported = cursor.getBooleanOrDefault(
COLUMN_SEARCH_WITH_FILTERS,
false,
),
isYearSupported = cursor.getBooleanOrDefault(COLUMN_YEAR, false),
isYearRangeSupported = cursor.getBooleanOrDefault(COLUMN_YEAR_RANGE, false),
isOriginalLocaleSupported = cursor.getBooleanOrDefault(COLUMN_ORIGINAL_LOCALE, false),
),
)
} else {
null
@@ -233,6 +266,26 @@ class ExternalPluginContentSource(
source = source,
)
private fun <E : Enum<E>> fetchEnumSet(cls: Class<E>, path: String): EnumSet<E> {
val uri = "content://${source.authority}/$path".toUri()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
val result = EnumSet.noneOf(cls)
val enumConstants = cls.enumConstants ?: return@use result
if (cursor.moveToFirst()) {
do {
val name = cursor.getString(COLUMN_NAME)
val enumValue = enumConstants.find { it.name == name }
if (enumValue != null) {
result.add(enumValue)
}
} while (cursor.moveToNext())
}
result
}
}
private fun Cursor?.safe() = ExternalPluginCursor(
source = source,
cursor = this ?: throw IncompatiblePluginException(source.name, null),
@@ -240,27 +293,19 @@ class ExternalPluginContentSource(
class MangaSourceCapabilities(
val availableSortOrders: Set<SortOrder>,
val availableStates: Set<MangaState>,
val availableContentRating: Set<ContentRating>,
val isMultipleTagsSupported: Boolean,
val isTagsExclusionSupported: Boolean,
val isSearchSupported: Boolean,
val contentType: ContentType,
val defaultSortOrder: SortOrder,
val sourceLocale: Locale,
val listFilterCapabilities: MangaListFilterCapabilities,
)
private companion object {
const val COLUMN_SORT_ORDERS = "sort_orders"
const val COLUMN_STATES = "states"
const val COLUMN_CONTENT_RATING = "content_rating"
const val COLUMN_MULTIPLE_TAGS_SUPPORTED = "multiple_tags_supported"
const val COLUMN_TAGS_EXCLUSION_SUPPORTED = "tags_exclusion_supported"
const val COLUMN_SEARCH_SUPPORTED = "search_supported"
const val COLUMN_CONTENT_TYPE = "content_type"
const val COLUMN_DEFAULT_SORT_ORDER = "default_sort_order"
const val COLUMN_LOCALE = "locale"
const val COLUMN_MULTIPLE_TAGS = "multiple_tags"
const val COLUMN_TAGS_EXCLUSION = "tags_exclusion"
const val COLUMN_SEARCH = "search"
const val COLUMN_SEARCH_WITH_FILTERS = "search_with_filters"
const val COLUMN_YEAR = "year"
const val COLUMN_YEAR_RANGE = "year_range"
const val COLUMN_ORIGINAL_LOCALE = "original_locale"
const val COLUMN_ID = "id"
const val COLUMN_NAME = "name"
const val COLUMN_NUMBER = "number"
@@ -282,5 +327,6 @@ class ExternalPluginContentSource(
const val COLUMN_DESCRIPTION = "description"
const val COLUMN_PREVIEW = "preview"
const val COLUMN_KEY = "key"
const val COLUMN_VALUE = "value"
}
}

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.parser.favicon
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.AdaptiveIconDrawable
import android.graphics.drawable.ColorDrawable
@@ -8,231 +7,130 @@ import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.net.Uri
import android.os.Build
import android.webkit.MimeTypeMap
import coil.ImageLoader
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.disk.DiskCache
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.network.HttpException
import coil.request.Options
import coil.size.Size
import coil.size.pxOrElse
import kotlinx.coroutines.Dispatchers
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DataSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.request.Options
import coil3.size.pxOrElse
import coil3.toAndroidUri
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.internal.closeQuietly
import okio.Closeable
import okio.buffer
import okio.IOException
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.util.ext.requireBody
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import java.net.HttpURLConnection
import org.koitharu.kotatsu.core.util.ext.fetch
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import kotlin.coroutines.coroutineContext
private const val FALLBACK_SIZE = 9999 // largest icon
import coil3.Uri as CoilUri
class FaviconFetcher(
private val okHttpClient: OkHttpClient,
private val diskCache: Lazy<DiskCache?>,
private val mangaSource: MangaSource,
private val uri: Uri,
private val options: Options,
private val imageLoader: ImageLoader,
private val mangaRepositoryFactory: MangaRepository.Factory,
) : Fetcher {
private val diskCacheKey
get() = options.diskCacheKey ?: "${mangaSource.name}x${options.size.toCacheKey()}"
override suspend fun fetch(): FetchResult? {
val mangaSource = MangaSource(uri.schemeSpecificPart)
private val fileSystem
get() = checkNotNull(diskCache.value).fileSystem
override suspend fun fetch(): FetchResult {
getCached(options)?.let { return it }
return when (val repo = mangaRepositoryFactory.create(mangaSource)) {
is ParserMangaRepository -> fetchParserFavicon(repo)
is ExternalMangaRepository -> fetchPluginIcon(repo)
is EmptyMangaRepository -> DrawableResult(
drawable = ColorDrawable(Color.WHITE),
is EmptyMangaRepository -> ImageFetchResult(
image = ColorDrawable(Color.WHITE).asImage(),
isSampled = false,
dataSource = DataSource.MEMORY,
)
else -> throw IllegalArgumentException("")
is LocalMangaRepository -> imageLoader.fetch(R.drawable.ic_storage, options)
else -> throw IllegalArgumentException("Unsupported repo ${repo.javaClass.simpleName}")
}
}
private suspend fun fetchParserFavicon(repo: ParserMangaRepository): FetchResult {
private suspend fun fetchParserFavicon(repository: ParserMangaRepository): FetchResult {
val sizePx = maxOf(
options.size.width.pxOrElse { FALLBACK_SIZE },
options.size.height.pxOrElse { FALLBACK_SIZE },
)
var favicons = repo.getFavicons()
var favicons = repository.getFavicons()
var lastError: Exception? = null
while (favicons.isNotEmpty()) {
coroutineContext.ensureActive()
val icon = favicons.find(sizePx) ?: throwNSEE(lastError)
val response = try {
loadIcon(icon.url, mangaSource)
try {
val result = imageLoader.fetch(icon.url, options)
if (result != null) {
return result
} else {
favicons -= icon
}
} catch (e: CloudFlareProtectedException) {
throw e
} catch (e: HttpException) {
} catch (e: IOException) {
lastError = e
favicons -= icon
continue
}
val responseBody = response.requireBody()
val source = writeToDiskCache(responseBody)?.toImageSource()?.also {
response.closeQuietly()
} ?: responseBody.toImageSource(response)
return SourceResult(
source = source,
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(icon.type),
dataSource = response.toDataSource(),
)
}
throwNSEE(lastError)
}
private suspend fun loadIcon(url: String, source: MangaSource): Response {
val request = Request.Builder()
.url(url)
.get()
.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()
if (!response.isSuccessful && response.code != HttpURLConnection.HTTP_NOT_MODIFIED) {
response.closeQuietly()
throw HttpException(response)
}
return response
}
private suspend fun fetchPluginIcon(repository: ExternalMangaRepository): FetchResult {
val source = repository.source
val pm = options.context.packageManager
val icon = runInterruptible(Dispatchers.IO) {
val icon = runInterruptible {
val provider = pm.resolveContentProvider(source.authority, 0)
provider?.loadIcon(pm) ?: pm.getApplicationIcon(source.packageName)
}
return DrawableResult(
drawable = icon.nonAdaptive(),
return ImageFetchResult(
image = icon.nonAdaptive().asImage(),
isSampled = false,
dataSource = DataSource.DISK,
)
}
private fun getCached(options: Options): SourceResult? {
if (!options.diskCachePolicy.readEnabled) {
return null
}
val snapshot = diskCache.value?.openSnapshot(diskCacheKey) ?: return null
return SourceResult(
source = snapshot.toImageSource(),
mimeType = null,
dataSource = DataSource.DISK,
)
}
private suspend fun writeToDiskCache(body: ResponseBody): DiskCache.Snapshot? {
if (!options.diskCachePolicy.writeEnabled || body.contentLength() == 0L) {
return null
}
val editor = diskCache.value?.openEditor(diskCacheKey) ?: return null
try {
fileSystem.write(editor.data) {
writeAllCancellable(body.source())
}
return editor.commitAndOpenSnapshot()
} catch (e: Throwable) {
try {
editor.abort()
} catch (abortingError: Throwable) {
e.addSuppressed(abortingError)
}
body.closeQuietly()
throw e
} finally {
body.closeQuietly()
}
}
private fun DiskCache.Snapshot.toImageSource(): ImageSource {
return ImageSource(data, fileSystem, diskCacheKey, this)
}
private fun ResponseBody.toImageSource(response: Closeable): ImageSource {
return ImageSource(
source().withExtraCloseable(response).buffer(),
options.context,
FaviconMetadata(mangaSource),
)
}
private fun Response.toDataSource(): DataSource {
return if (networkResponse != null) DataSource.NETWORK else DataSource.DISK
}
private fun Size.toCacheKey() = buildString {
append(width.toString())
append('x')
append(height.toString())
}
private fun throwNSEE(lastError: Exception?): Nothing {
if (lastError != null) {
throw lastError
} else {
throw NoSuchElementException("No favicons found")
}
}
private fun Drawable.nonAdaptive() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this is AdaptiveIconDrawable) {
LayerDrawable(arrayOf(background, foreground))
} else {
this
}
class Factory(
context: Context,
okHttpClientLazy: Lazy<OkHttpClient>,
private val mangaRepositoryFactory: MangaRepository.Factory,
) : Fetcher.Factory<Uri> {
) : Fetcher.Factory<CoilUri> {
private val okHttpClient by okHttpClientLazy
private val diskCache = lazy {
val rootDir = context.externalCacheDir ?: context.cacheDir
DiskCache.Builder()
.directory(rootDir.resolve(CacheDir.FAVICONS.dir))
.build()
}
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
return if (data.scheme == URI_SCHEME_FAVICON) {
val mangaSource = MangaSource(data.schemeSpecificPart)
FaviconFetcher(okHttpClient, diskCache, mangaSource, options, mangaRepositoryFactory)
} else {
null
}
override fun create(
data: CoilUri,
options: Options,
imageLoader: ImageLoader
): Fetcher? = if (data.scheme == URI_SCHEME_FAVICON) {
FaviconFetcher(data.toAndroidUri(), options, imageLoader, mangaRepositoryFactory)
} else {
null
}
}
class FaviconMetadata(val source: MangaSource) : ImageSource.Metadata()
private companion object {
const val FALLBACK_SIZE = 9999 // largest icon
private fun throwNSEE(lastError: Exception?): Nothing {
if (lastError != null) {
throw lastError
} else {
throw NoSuchElementException("No favicons found")
}
}
private fun Drawable.nonAdaptive() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this is AdaptiveIconDrawable) {
LayerDrawable(arrayOf(background, foreground))
} else {
this
}
}
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.ActivityInfo
import android.net.ConnectivityManager
import android.net.Uri
import android.os.Build
@@ -29,10 +30,12 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import java.io.File
import java.net.Proxy
import java.util.EnumSet
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@@ -119,6 +122,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
val readerScreenOrientation: Int
get() = prefs.getString(KEY_READER_ORIENTATION, null)?.toIntOrNull()
?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
val isReaderVolumeButtonsEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_VOLUME_BUTTONS, false)
@@ -134,10 +141,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderOptimizationEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false)
var isTrafficWarningEnabled: Boolean
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
val isOfflineCheckDisabled: Boolean
get() = prefs.getBoolean(KEY_OFFLINE_DISABLED, false)
@@ -160,6 +163,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isTrackerNsfwDisabled: Boolean
get() = prefs.getBoolean(KEY_TRACKER_NO_NSFW, false)
val trackerDownloadStrategy: TrackerDownloadStrategy
get() = prefs.getEnumValue(KEY_TRACKER_DOWNLOAD, TrackerDownloadStrategy.DISABLED)
var notificationSound: Uri
get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull()
?: Settings.System.DEFAULT_NOTIFICATION_URI
@@ -236,9 +242,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
}
} ?: EnumSet.allOf(SearchSuggestionType::class.java)
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) }
@@ -328,8 +331,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
}
}
val isDownloadsWiFiOnly: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false)
var allowDownloadOnMeteredNetwork: TriStateOption
get() = prefs.getEnumValue(KEY_DOWNLOADS_METERED_NETWORK, TriStateOption.ASK)
set(value) = prefs.edit { putEnumValue(KEY_DOWNLOADS_METERED_NETWORK, value) }
val preferredDownloadFormat: DownloadFormat
get() = prefs.getEnumValue(KEY_DOWNLOADS_FORMAT, DownloadFormat.AUTOMATIC)
@@ -409,10 +413,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getString(KEY_PROXY_PORT, null)?.toIntOrNull() ?: 0
val proxyLogin: String?
get() = prefs.getString(KEY_PROXY_LOGIN, null)?.takeUnless { it.isEmpty() }
get() = prefs.getString(KEY_PROXY_LOGIN, null)?.nullIfEmpty()
val proxyPassword: String?
get() = prefs.getString(KEY_PROXY_PASSWORD, null)?.takeUnless { it.isEmpty() }
get() = prefs.getString(KEY_PROXY_PASSWORD, null)?.nullIfEmpty()
var localListOrder: SortOrder
get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST)
@@ -471,7 +475,17 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val periodicalBackupFrequency: Long
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L
var periodicalBackupOutput: Uri?
val periodicalBackupFrequencyMillis: Long
get() = TimeUnit.DAYS.toMillis(periodicalBackupFrequency)
val periodicalBackupMaxCount: Int
get() = if (prefs.getBoolean(KEY_BACKUP_PERIODICAL_TRIM, true)) {
prefs.getInt(KEY_BACKUP_PERIODICAL_COUNT, 10)
} else {
Int.MAX_VALUE
}
var periodicalBackupDirectory: Uri?
get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull()
set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) }
@@ -573,7 +587,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_THEME = "theme"
const val KEY_COLOR_THEME = "color_theme"
const val KEY_THEME_AMOLED = "amoled_theme"
const val KEY_TRAFFIC_WARNING = "traffic_warning"
const val KEY_OFFLINE_DISABLED = "no_offline"
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear"
@@ -592,6 +605,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
const val KEY_READER_FULLSCREEN = "reader_fullscreen"
const val KEY_READER_VOLUME_BUTTONS = "reader_volume_buttons"
const val KEY_READER_ORIENTATION = "reader_orientation"
const val KEY_TRACKER_ENABLED = "tracker_enabled"
const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi"
const val KEY_TRACKER_FREQUENCY = "tracker_freq"
@@ -600,6 +614,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_TRACK_WARNING = "track_warning"
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
const val KEY_TRACKER_NO_NSFW = "tracker_no_nsfw"
const val KEY_TRACKER_DOWNLOAD = "tracker_download"
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
@@ -618,6 +633,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_RESTORE = "restore"
const val KEY_BACKUP_PERIODICAL_ENABLED = "backup_periodic"
const val KEY_BACKUP_PERIODICAL_FREQUENCY = "backup_periodic_freq"
const val KEY_BACKUP_PERIODICAL_TRIM = "backup_periodic_trim"
const val KEY_BACKUP_PERIODICAL_COUNT = "backup_periodic_count"
const val KEY_BACKUP_PERIODICAL_OUTPUT = "backup_periodic_output"
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
const val KEY_HISTORY_GROUPING = "history_grouping"
@@ -638,7 +655,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_ANILIST = "anilist"
const val KEY_MAL = "mal"
const val KEY_KITSU = "kitsu"
const val KEY_DOWNLOADS_WIFI = "downloads_wifi"
const val KEY_DOWNLOADS_METERED_NETWORK = "downloads_metered_network"
const val KEY_DOWNLOADS_FORMAT = "downloads_format"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
const val KEY_DOH = "doh"
@@ -661,7 +678,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_WEBTOON_ZOOM_OUT = "webtoon_zoom_out"
const val KEY_PREFETCH_CONTENT = "prefetch_content"
const val KEY_APP_LOCALE = "app_locale"
const val KEY_LOGGING_ENABLED = "logging"
const val KEY_SOURCES_GRID = "sources_grid"
const val KEY_UPDATES_UNSTABLE = "updates_unstable"
const val KEY_TIPS_CLOSED = "tips_closed"
@@ -669,7 +685,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_AUTOSCROLL_SPEED = "as_speed"
const val KEY_MIRROR_SWITCHING = "mirror_switching"
const val KEY_PROXY = "proxy"
const val KEY_PROXY_TYPE = "proxy_type"
const val KEY_PROXY_TYPE = "proxy_type_2"
const val KEY_PROXY_ADDRESS = "proxy_address"
const val KEY_PROXY_PORT = "proxy_port"
const val KEY_PROXY_AUTH = "proxy_auth"
@@ -705,10 +721,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_APP_VERSION = "app_version"
const val KEY_IGNORE_DOZE = "ignore_dose"
const val KEY_TRACKER_DEBUG = "tracker_debug"
const val KEY_LOGS_SHARE = "logs_share"
const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_TRANSLATION = "about_app_translation"
const val PROXY_TEST = "proxy_test"
const val KEY_LINK_WEBLATE = "about_app_translation"
const val KEY_LINK_TELEGRAM = "about_telegram"
const val KEY_LINK_GITHUB = "about_github"
const val KEY_LINK_MANUAL = "about_help"
const val KEY_PROXY_TEST = "proxy_test"
const val KEY_OPEN_BROWSER = "open_browser"
const val KEY_HANDLE_LINKS = "handle_links"
// old keys are for migration only
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"

View File

@@ -1,11 +1,13 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
import androidx.annotation.StringRes
import androidx.annotation.StyleRes
import com.google.android.material.color.DynamicColors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.find
@Keep
enum class ColorScheme(
@StyleRes val styleResId: Int,
@StringRes val titleResId: Int,

View File

@@ -1,5 +1,8 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
@Keep
enum class DownloadFormat {
AUTOMATIC,

View File

@@ -1,6 +1,9 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
@Keep
enum class ListMode {
LIST, DETAILED_LIST, GRID;
}
}

View File

@@ -2,9 +2,11 @@ package org.koitharu.kotatsu.core.prefs
import androidx.annotation.DrawableRes
import androidx.annotation.IdRes
import androidx.annotation.Keep
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
@Keep
enum class NavItem(
@IdRes val id: Int,
@StringRes val title: Int,

View File

@@ -1,7 +1,9 @@
package org.koitharu.kotatsu.core.prefs
import android.net.ConnectivityManager
import androidx.annotation.Keep
@Keep
enum class NetworkPolicy(
private val key: Int,
) {

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