Compare commits

..

189 Commits

Author SHA1 Message Date
Koitharu
0153e90bf0 Fix build 2025-01-22 20:35:51 +02:00
Koitharu
d4f8fe83f5 Release signing config 2025-01-22 20:27:30 +02:00
Koitharu
d28b1e4094 Release build workflow 2025-01-22 20:22:03 +02:00
Koitharu
cd2de0136a Support for dynamic version 2025-01-22 20:22:02 +02:00
Koitharu
15e99c03a9 Update parsers and adjust imports
(cherry picked from commit 5e8aa4cec7)
2025-01-22 20:21:43 +02:00
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
439 changed files with 7173 additions and 3524 deletions

114
.github/workflows/auto_release.yml vendored Normal file
View File

@@ -0,0 +1,114 @@
name: Build automatic release
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-24.04
outputs:
should_build: ${{ steps.check-updates.outputs.has_changes }}
steps:
- name: Check for updates 🌏
id: check-updates
run: |
last_run=$(curl -s "https://api.github.com/repos/${{ github.repository }}/releases/latest" | jq -r '.created_at')
kotatsu_updated=$(curl -s "https://api.github.com/repos/KotatsuApp/Kotatsu/commits?since=$last_run" | jq '. | length')
parsers_updated=$(curl -s "https://api.github.com/repos/KotatsuApp/kotatsu-parsers/commits?since=$last_run" | jq '. | length')
if [ "$kotatsu_updated" -gt "0" ] || [ "$parsers_updated" -gt "0" ]; then
echo "has_changes=true" >> $GITHUB_OUTPUT
else
echo "has_changes=false" >> $GITHUB_OUTPUT
fi
build:
needs: check
if: needs.check.outputs.should_build == 'true'
runs-on: ubuntu-24.04
outputs:
new_tag: ${{ steps.tagger.outputs.new_tag }}
steps:
- uses: actions/checkout@v3
with:
ref: autobuild
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
- name: Setup Android SDK 💻
uses: android-actions/setup-android@v3
- name: Grant permissions 💻
run: chmod a+x gradlew
- name: Generate build number 📆
id: tagger
run: |
echo "new_tag=$(./gradlew -q versionInfo -DbuildNumberIncrement=true)" >> $GITHUB_OUTPUT
echo "formatted_date=$(date +'%Y/%m/%d')" >> $GITHUB_OUTPUT
- name: Decode Keystore
id: decode_keystore
uses: timheuer/base64-to-file@v1
with:
fileName: 'keystore/kotatsu.jks'
encodedString: ${{ secrets.ANDROID_SIGNING_KEY }}
- name: Building new APK 💻
run: >-
./gradlew assembleRelease
-DparsersVersionOverride=$(curl -s https://api.github.com/repos/kotatsuapp/kotatsu-parsers/commits/master -H "Accept: application/vnd.github.sha" | cut -c -10)
- name: Prepare to Upload 🌏
run: |
mv ${{steps.sign_app.outputs.signedFile}} app/build/outputs/apk/release/release.apk
echo "SIGNED_APK=app/build/outputs/apk/release/release.apk" >> $GITHUB_ENV
- name: Get latest changes 📑
id: changelog
run: |
CHANGELOG=$(cat CHANGELOG.txt)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CHANGELOG" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create new GH Release + Uploading 🌏
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.tagger.outputs.new_tag }}
name: "Build ${{ steps.tagger.outputs.new_tag }}"
body: |
Automated build generated on ${{ steps.tagger.outputs.formatted_date }}
${{ steps.changelog.outputs.content }}
files: ${{ env.SIGNED_APK }}
prerelease: false
update:
needs: build
if: needs.check.outputs.should_build == 'true'
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v3
with:
ref: autobuild
fetch-depth: 0
- name: Commit 🌏
run: |
git config --local user.email "autorelease@users.noreply.github.com"
git config --local user.name "autorelease"
if [[ -n $(git status -s) ]]; then
git add README.md
git commit -m "Automatic release v${{ needs.build.outputs.new_tag }}"
git push origin autobuild
else
echo "No changes to push!"
fi

1
.gitignore vendored
View File

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

View File

@@ -2,12 +2,13 @@
Kotatsu is a free and open-source manga reader for Android with built-in online content sources. Kotatsu is a free and open-source manga reader for Android with built-in online content sources.
[![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) ![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) [![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 ### Download
- **Recommended:** Download and install APK from **[GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest)**. Application has a built-in self-updating feature. - **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. - 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 ### Main Features

View File

@@ -1,3 +1,5 @@
import java.time.LocalDateTime
plugins { plugins {
id 'com.android.application' id 'com.android.application'
id 'kotlin-android' id 'kotlin-android'
@@ -7,17 +9,24 @@ plugins {
id 'dagger.hilt.android.plugin' id 'dagger.hilt.android.plugin'
} }
def Properties versionProps = getVersionProps()
android { android {
compileSdk = 35 compileSdk = 35
buildToolsVersion = '35.0.0' buildToolsVersion = '35.0.0'
namespace = 'org.koitharu.kotatsu' namespace = 'org.koitharu.kotatsu'
defaultConfig { defaultConfig {
def code = versionProps['code'].toInteger()
def base = versionProps['base'].trim()
def build = versionProps['build'].toInteger()
def variant = versionProps['variant'].trim()
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 35 targetSdk = 35
versionCode = 676 versionCode = code * 1000 + build
versionName = '7.6.3' versionName = base + (build == 0 ? '' : '.' + build) + (variant == '' ? '' : '-') + variant
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {
@@ -28,6 +37,22 @@ android {
generateLocaleConfig true generateLocaleConfig true
} }
} }
signingConfigs {
release {
def tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
def allFilesFromDir = new File(tmpFilePath).listFiles()
if (allFilesFromDir != null) {
def keystoreFile = allFilesFromDir.first()
keystoreFile.renameTo("keystore/kotatsu.jks")
}
storeFile = file("keystore/kotatsu.jks")
storePassword System.getenv("SIGNING_STORE_PASSWORD")
keyAlias System.getenv("SIGNING_KEY_ALIAS")
keyPassword System.getenv("SIGNING_KEY_PASSWORD")
}
}
buildTypes { buildTypes {
debug { debug {
applicationIdSuffix = '.debug' applicationIdSuffix = '.debug'
@@ -36,12 +61,25 @@ android {
minifyEnabled true minifyEnabled true
shrinkResources true shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
nightly {
initWith release
applicationIdSuffix = '.nightly'
} }
} }
buildFeatures { buildFeatures {
viewBinding true viewBinding true
buildConfig true buildConfig true
} }
packagingOptions {
resources {
excludes += [
'META-INF/README.md',
'META-INF/NOTICE.md'
]
}
}
sourceSets { sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
main.java.srcDirs += 'src/main/kotlin/' main.java.srcDirs += 'src/main/kotlin/'
@@ -59,12 +97,12 @@ android {
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi', '-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
'-opt-in=kotlinx.coroutines.FlowPreview', '-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts', '-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=coil.annotation.ExperimentalCoilApi', '-opt-in=coil3.annotation.ExperimentalCoilApi',
] ]
} }
lint { lint {
abortOnError true abortOnError true
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled' disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat'
} }
testOptions { testOptions {
unitTests.includeAndroidResources true unitTests.includeAndroidResources true
@@ -73,6 +111,15 @@ android {
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi'] 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 { afterEvaluate {
compileDebugKotlin { compileDebugKotlin {
@@ -82,86 +129,111 @@ afterEvaluate {
} }
} }
dependencies { dependencies {
implementation('com.github.KotatsuApp:kotatsu-parsers:1ebb298cd7') { 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' exclude group: 'org.json', module: 'json'
} }
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2' coreLibraryDesugaring libs.desugar.jdk.libs
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.20' implementation libs.kotlin.stdlib
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0' implementation libs.kotlinx.coroutines.android
implementation libs.kotlinx.coroutines.guava
implementation 'androidx.appcompat:appcompat:1.7.0' implementation libs.androidx.appcompat
implementation 'androidx.core:core-ktx:1.13.1' implementation libs.androidx.core
implementation 'androidx.activity:activity-ktx:1.9.2' implementation libs.androidx.activity
implementation 'androidx.fragment:fragment-ktx:1.8.4' implementation libs.androidx.fragment
implementation 'androidx.transition:transition-ktx:1.5.1' implementation libs.androidx.transition
implementation 'androidx.collection:collection-ktx:1.4.4' implementation libs.androidx.collection
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6' implementation libs.lifecycle.viewmodel
implementation 'androidx.lifecycle:lifecycle-service:2.8.6' implementation libs.lifecycle.service
implementation 'androidx.lifecycle:lifecycle-process:2.8.6' implementation libs.lifecycle.process
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation libs.androidx.constraintlayout
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation libs.androidx.swiperefreshlayout
implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation libs.androidx.recyclerview
implementation 'androidx.viewpager2:viewpager2:1.1.0' implementation libs.androidx.viewpager2
implementation 'androidx.preference:preference-ktx:1.2.1' implementation libs.androidx.preference
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation libs.androidx.biometric
implementation 'com.google.android.material:material:1.12.0' implementation libs.material
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.6' implementation libs.androidx.lifecycle.common.java8
implementation 'androidx.webkit:webkit:1.11.0' implementation libs.androidx.webkit
implementation 'androidx.work:work-runtime:2.9.1' implementation libs.androidx.work.runtime
//noinspection GradleDependency implementation libs.guava
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 'androidx.room:room-runtime:2.6.1' implementation libs.androidx.room.runtime
implementation 'androidx.room:room-ktx:2.6.1' implementation libs.androidx.room.ktx
ksp 'androidx.room:room-compiler:2.6.1' ksp libs.androidx.room.compiler
implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation libs.okhttp
implementation 'com.squareup.okhttp3:okhttp-tls:4.12.0' implementation libs.okhttp.tls
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0' implementation libs.okhttp.dnsoverhttps
implementation 'com.squareup.okio:okio:3.9.1' implementation libs.okio
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation libs.adapterdelegates
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' implementation libs.adapterdelegates.viewbinding
implementation 'com.google.dagger:hilt-android:2.52' implementation libs.hilt.android
kapt 'com.google.dagger:hilt-compiler:2.52' kapt libs.hilt.compiler
implementation 'androidx.hilt:hilt-work:1.2.0' implementation libs.androidx.hilt.work
kapt 'androidx.hilt:hilt-compiler:1.2.0' kapt libs.androidx.hilt.compiler
implementation 'io.coil-kt:coil-base:2.7.0' implementation libs.coil.core
implementation 'io.coil-kt:coil-svg:2.7.0' implementation libs.coil.network
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:e04098de68' implementation libs.coil.gif
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation libs.coil.svg
implementation 'io.noties.markwon:core:4.6.2' implementation libs.avif.decoder
implementation libs.ssiv
implementation libs.disk.lru.cache
implementation libs.markwon
implementation 'ch.acra:acra-http:5.11.4' implementation libs.acra.http
implementation 'ch.acra:acra-dialog:5.11.4' implementation libs.acra.dialog
implementation 'org.conscrypt:conscrypt-android:2.5.2' implementation libs.conscrypt.android
debugImplementation 'com.squareup.leakcanary:leakcanary-android:3.0-alpha-8' debugImplementation libs.leakcanary.android
debugImplementation 'com.github.Koitharu:WorkInspector:5778dd1747' debugImplementation libs.workinspector
testImplementation 'junit:junit:4.13.2' testImplementation libs.junit
testImplementation 'org.json:json:20240303' testImplementation libs.json
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0' testImplementation libs.kotlinx.coroutines.test
androidTestImplementation 'androidx.test:runner:1.6.1' androidTestImplementation libs.androidx.runner
androidTestImplementation 'androidx.test:rules:1.6.1' androidTestImplementation libs.androidx.rules
androidTestImplementation 'androidx.test:core-ktx:1.6.1' androidTestImplementation libs.androidx.test.core
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1' androidTestImplementation libs.androidx.junit
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0' androidTestImplementation libs.kotlinx.coroutines.test
androidTestImplementation 'androidx.room:room-testing:2.6.1' androidTestImplementation libs.androidx.room.testing
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1' androidTestImplementation libs.moshi.kotlin
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.52' androidTestImplementation libs.hilt.android.testing
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.52' kaptAndroidTest libs.hilt.android.compiler
}
tasks.register('versionInfo') {
def base = versionProps['base'].trim()
def build = versionProps['build'].toInteger()
def variant = versionProps['variant'].trim()
println base + (build == 0 ? '' : '.' + build) + (variant == '' ? '' : '-') + variant
}
def getVersionProps() {
def versionPropsFile = file('version.properties')
def Properties versionProps = new Properties()
versionProps.load(new FileInputStream(versionPropsFile))
if (System.getProperty('buildNumberIncrement') == 'true') {
def code = versionProps['build'].toInteger() + 1
versionProps['build'] = code.toString()
versionProps.store(versionPropsFile.newWriter(), null)
}
return versionProps
} }

View File

@@ -15,6 +15,7 @@
-dontwarn org.bouncycastle.** -dontwarn org.bouncycastle.**
-dontwarn org.openjsse.** -dontwarn org.openjsse.**
-dontwarn com.google.j2objc.annotations.** -dontwarn com.google.j2objc.annotations.**
-dontwarn coil3.PlatformContext
-keep class org.koitharu.kotatsu.core.exceptions.* { *; } -keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment -keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
@@ -26,3 +27,4 @@
-keep class org.acra.security.NoKeyStoreFactory { *; } -keep class org.acra.security.NoKeyStoreFactory { *; }
-keep class org.acra.config.DefaultRetryPolicy { *; } -keep class org.acra.config.DefaultRetryPolicy { *; }
-keep class org.acra.attachment.DefaultAttachmentProvider { *; } -keep class org.acra.attachment.DefaultAttachmentProvider { *; }
-keep class org.acra.sender.JobSenderService

View File

@@ -9,11 +9,12 @@ import android.os.Build
import android.os.StrictMode import android.os.StrictMode
import android.os.strictmode.Violation import android.os.strictmode.Violation
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.PendingIntentCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.fragment.app.strictmode.FragmentStrictMode import androidx.fragment.app.strictmode.FragmentStrictMode
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor import kotlinx.coroutines.asExecutor
import org.koitharu.kotatsu.core.ErrorReporterReceiver import org.koitharu.kotatsu.core.util.ShareHelper
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import androidx.fragment.app.strictmode.Violation as FragmentViolation import androidx.fragment.app.strictmode.Violation as FragmentViolation
@@ -42,7 +43,7 @@ class StrictModeNotifier(
override fun onViolation(violation: FragmentViolation) = showNotification(violation) override fun onViolation(violation: FragmentViolation) = showNotification(violation)
private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID) private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_error) .setSmallIcon(R.drawable.ic_bug)
.setContentTitle(context.getString(R.string.strict_mode)) .setContentTitle(context.getString(R.string.strict_mode))
.setContentText(violation.message) .setContentText(violation.message)
.setStyle( .setStyle(
@@ -51,7 +52,15 @@ class StrictModeNotifier(
.setSummaryText(violation.message) .setSummaryText(violation.message)
.bigText(violation.stackTraceToString()), .bigText(violation.stackTraceToString()),
).setShowWhen(true) ).setShowWhen(true)
.setContentIntent(ErrorReporterReceiver.getPendingIntent(context, violation)) .setContentIntent(
PendingIntentCompat.getActivity(
context,
0,
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
0,
false,
),
)
.setAutoCancel(true) .setAutoCancel(true)
.setGroup(CHANNEL_ID) .setGroup(CHANNEL_ID)
.build() .build()

View File

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

View File

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

@@ -46,7 +46,7 @@
android:allowBackup="true" android:allowBackup="true"
android:backupAgent="org.koitharu.kotatsu.settings.backup.AppBackupAgent" android:backupAgent="org.koitharu.kotatsu.settings.backup.AppBackupAgent"
android:dataExtractionRules="@xml/backup_rules" android:dataExtractionRules="@xml/backup_rules"
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled"
android:fullBackupContent="@xml/backup_content" android:fullBackupContent="@xml/backup_content"
android:fullBackupOnly="true" android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -209,6 +209,7 @@
<activity android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity" /> <activity android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity" />
<activity <activity
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity" android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity"
android:exported="true"
android:label="@string/sync" /> android:label="@string/sync" />
<activity <activity
android:name="org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity" android:name="org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity"
@@ -266,19 +267,30 @@
tools:node="merge" /> tools:node="merge" />
<service <service
android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync"
android:label="@string/local_manga_processing" />
<service <service
android:name="org.koitharu.kotatsu.local.ui.ImportService" android:name="org.koitharu.kotatsu.settings.backup.PeriodicalBackupService"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync"
android:label="@string/periodic_backups" />
<service <service
android:name="org.koitharu.kotatsu.alternatives.ui.AutoFixService" android:name="org.koitharu.kotatsu.alternatives.ui.AutoFixService"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync"
<service android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService" /> android:label="@string/fixing_manga" />
<service
android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService"
android:label="@string/local_manga_processing" />
<service
android:name="org.koitharu.kotatsu.local.ui.ImportService"
android:foregroundServiceType="dataSync"
android:label="@string/importing_manga" />
<service <service
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService" android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
android:label="@string/manga_shelf"
android:permission="android.permission.BIND_REMOTEVIEWS" /> android:permission="android.permission.BIND_REMOTEVIEWS" />
<service <service
android:name="org.koitharu.kotatsu.widget.recent.RecentWidgetService" android:name="org.koitharu.kotatsu.widget.recent.RecentWidgetService"
android:label="@string/recent_manga"
android:permission="android.permission.BIND_REMOTEVIEWS" /> android:permission="android.permission.BIND_REMOTEVIEWS" />
<service <service
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthenticatorService" android:name="org.koitharu.kotatsu.sync.ui.SyncAuthenticatorService"
@@ -315,7 +327,8 @@
</service> </service>
<service <service
android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService" android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService"
android:exported="false" /> android:exported="false"
android:label="@string/prefetch_content" />
<provider <provider
android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider" android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider"
@@ -394,7 +407,7 @@
android:value="@bool/com_samsung_android_icon_container_has_icon_container" /> android:value="@bool/com_samsung_android_icon_container_has_icon_container" />
<activity-alias <activity-alias
android:name="org.koitharu.kotatsu.details.ui.DetailsBYLinkActivity" android:name="org.koitharu.kotatsu.details.ui.DetailsByLinkActivity"
android:exported="true" android:exported="true"
android:targetActivity="org.koitharu.kotatsu.details.ui.DetailsActivity"> android:targetActivity="org.koitharu.kotatsu.details.ui.DetailsActivity">

View File

@@ -8,13 +8,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.almostEquals
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.almostEquals
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject

View File

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

View File

@@ -8,7 +8,7 @@ import android.widget.Toast
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import coil.ImageLoader import coil3.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver

View File

@@ -12,8 +12,8 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import coil.ImageLoader import coil3.ImageLoader
import coil.request.ImageRequest import coil3.request.ImageRequest
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -23,6 +23,7 @@ import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.ui.CoroutineIntentService import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage 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.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
@@ -47,25 +48,21 @@ class AutoFixService : CoroutineIntentService() {
notificationManager = NotificationManagerCompat.from(applicationContext) notificationManager = NotificationManagerCompat.from(applicationContext)
} }
override suspend fun processIntent(startId: Int, intent: Intent) { override suspend fun IntentJobContext.processIntent(intent: Intent) {
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS)) val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
startForeground(startId) startForeground(this)
try { for (mangaId in ids) {
for (mangaId in ids) { val result = runCatchingCancellable {
val result = runCatchingCancellable { autoFixUseCase.invoke(mangaId)
autoFixUseCase.invoke(mangaId) }
} if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { val notification = buildNotification(result)
val notification = buildNotification(result) notificationManager.notify(TAG, startId, notification)
notificationManager.notify(TAG, startId, notification)
}
} }
} finally {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
} }
} }
override fun onError(startId: Int, error: Throwable) { override fun IntentJobContext.onError(error: Throwable) {
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
val notification = runBlocking { buildNotification(Result.failure(error)) } val notification = runBlocking { buildNotification(Result.failure(error)) }
notificationManager.notify(TAG, startId, notification) notificationManager.notify(TAG, startId, notification)
@@ -73,7 +70,7 @@ class AutoFixService : CoroutineIntentService() {
} }
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
private fun startForeground(startId: Int) { private fun startForeground(jobContext: IntentJobContext) {
val title = applicationContext.getString(R.string.fixing_manga) val title = applicationContext.getString(R.string.fixing_manga)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN) val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
.setName(title) .setName(title)
@@ -97,12 +94,11 @@ class AutoFixService : CoroutineIntentService() {
.addAction( .addAction(
materialR.drawable.material_ic_clear_black_24dp, materialR.drawable.material_ic_clear_black_24dp,
applicationContext.getString(android.R.string.cancel), applicationContext.getString(android.R.string.cancel),
getCancelIntent(startId), jobContext.getCancelIntent(),
) )
.build() .build()
ServiceCompat.startForeground( jobContext.setForeground(
this,
FOREGROUND_NOTIFICATION_ID, FOREGROUND_NOTIFICATION_ID,
notification, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
@@ -121,7 +117,7 @@ class AutoFixService : CoroutineIntentService() {
coil.execute( coil.execute(
ImageRequest.Builder(applicationContext) ImageRequest.Builder(applicationContext)
.data(replacement.coverUrl) .data(replacement.coverUrl)
.tag(replacement.source) .mangaSourceExtra(replacement.source)
.build(), .build(),
).toBitmapOrNull(), ).toBitmapOrNull(),
) )

View File

@@ -14,7 +14,7 @@ import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import coil.ImageLoader import coil3.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark

View File

@@ -1,17 +1,18 @@
package org.koitharu.kotatsu.bookmarks.ui.adapter package org.koitharu.kotatsu.bookmarks.ui.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil3.ImageLoader
import coil3.request.allowRgb565
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.bookmarkExtra
import org.koitharu.kotatsu.core.util.ext.decodeRegion import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -29,9 +30,8 @@ fun bookmarkLargeAD(
size(CoverSizeResolver(binding.imageViewThumb)) size(CoverSizeResolver(binding.imageViewThumb))
defaultPlaceholders(context) defaultPlaceholders(context)
allowRgb565(true) allowRgb565(true)
tag(item) bookmarkExtra(item)
decodeRegion(item.scroll) decodeRegion(item.scroll)
source(item.manga.source)
enqueueWith(coil) enqueueWith(coil)
} }
binding.progressView.setProgress(item.percent, false) binding.progressView.setProgress(item.percent, false)

View File

@@ -1,19 +1,21 @@
package org.koitharu.kotatsu.bookmarks.ui.adapter package org.koitharu.kotatsu.bookmarks.ui.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil3.ImageLoader
import coil3.request.allowRgb565
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.bookmarkExtra
import org.koitharu.kotatsu.core.util.ext.decodeRegion import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
// TODO check usages
fun bookmarkListAD( fun bookmarkListAD(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
@@ -28,9 +30,8 @@ fun bookmarkListAD(
size(CoverSizeResolver(binding.imageViewThumb)) size(CoverSizeResolver(binding.imageViewThumb))
defaultPlaceholders(context) defaultPlaceholders(context)
allowRgb565(true) allowRgb565(true)
tag(item) bookmarkExtra(item)
decodeRegion(item.scroll) decodeRegion(item.scroll)
source(item.manga.source)
enqueueWith(coil) enqueueWith(coil)
} }
} }

View File

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

View File

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

View File

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

View File

@@ -78,6 +78,9 @@ open class BaseApp : Application(), Configuration.Provider {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if (ACRA.isACRASenderServiceProcess()) {
return
}
AppCompatDelegate.setDefaultNightMode(settings.theme) AppCompatDelegate.setDefaultNightMode(settings.theme)
AppCompatDelegate.setApplicationLocales(settings.appLocales) AppCompatDelegate.setApplicationLocales(settings.appLocales)
// TLS 1.3 support for Android < 10 // TLS 1.3 support for Android < 10

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

View File

@@ -1,14 +1,11 @@
package org.koitharu.kotatsu.core.backup package org.koitharu.kotatsu.core.backup
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import okio.Closeable import okio.Closeable
import org.json.JSONArray import org.json.JSONArray
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import java.io.File import java.io.File
import java.util.EnumSet import java.util.EnumSet
import java.util.zip.ZipException import java.util.zip.ZipException
@@ -36,13 +33,9 @@ class BackupZipInput private constructor(val file: File) : Closeable {
zipFile.close() zipFile.close()
} }
fun cleanupAsync() { fun closeAndDelete() {
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) { closeQuietly()
runCatching { file.delete()
closeQuietly()
file.delete()
}
}
} }
companion object { companion object {
@@ -55,7 +48,7 @@ class BackupZipInput private constructor(val file: File) : Closeable {
throw BadBackupFormatException(null) throw BadBackupFormatException(null)
} }
res res
} catch (exception: Exception) { } catch (exception: Throwable) {
res?.closeQuietly() res?.closeQuietly()
throw if (exception is ZipException) { throw if (exception is ZipException) {
BadBackupFormatException(exception) BadBackupFormatException(exception)

View File

@@ -5,10 +5,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okio.Closeable import okio.Closeable
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.zip.ZipOutput import org.koitharu.kotatsu.core.zip.ZipOutput
import java.io.File import java.io.File
import java.time.LocalDate import java.text.ParseException
import java.time.format.DateTimeFormatter import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.zip.Deflater import java.util.zip.Deflater
@@ -27,20 +29,32 @@ class BackupZipOutput(val file: File) : Closeable {
override fun close() { override fun close() {
output.close() output.close()
} }
}
const val DIR_BACKUPS = "backups" companion object {
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) { const val DIR_BACKUPS = "backups"
val dir = context.run { private val dateTimeFormat = SimpleDateFormat("yyyyMMdd-HHmm")
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
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

@@ -1,11 +1,11 @@
package org.koitharu.kotatsu.core.db.entity package org.koitharu.kotatsu.core.db.entity
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
@@ -49,7 +49,7 @@ fun Manga.toEntity() = MangaEntity(
publicUrl = publicUrl, publicUrl = publicUrl,
source = source.name, source = source.name,
largeCoverUrl = largeCoverUrl, largeCoverUrl = largeCoverUrl,
coverUrl = coverUrl, coverUrl = coverUrl.orEmpty(),
altTitle = altTitle, altTitle = altTitle,
rating = rating, rating = rating,
isNsfw = isNsfw, isNsfw = isNsfw,

View File

@@ -14,7 +14,7 @@ data class MangaEntity(
@ColumnInfo(name = "url") val url: String, @ColumnInfo(name = "url") val url: String,
@ColumnInfo(name = "public_url") val publicUrl: String, @ColumnInfo(name = "public_url") val publicUrl: String,
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1 @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 = "cover_url") val coverUrl: String,
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?, @ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
@ColumnInfo(name = "state") val state: String?, @ColumnInfo(name = "state") val state: String?,

View File

@@ -1,3 +1,5 @@
package org.koitharu.kotatsu.core.exceptions 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

@@ -3,5 +3,5 @@ package org.koitharu.kotatsu.core.exceptions
import okio.IOException import okio.IOException
class NoDataReceivedException( class NoDataReceivedException(
url: String, val url: String,
) : IOException("No data has been received from $url") ) : 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.R
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.ParseException
class DialogErrorObserver( class DialogErrorObserver(
@@ -32,7 +33,7 @@ class DialogErrorObserver(
dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener) dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener)
} else if (value is ParseException) { } else if (value is ParseException) {
val fm = fragmentManager val fm = fragmentManager
if (fm != null) { if (fm != null && value.isSerializable()) {
dialogBuilder.setPositiveButton(R.string.details) { _, _ -> dialogBuilder.setPositiveButton(R.string.details) { _, _ ->
ErrorDetailsDialog.show(fm, value, value.url) ErrorDetailsDialog.show(fm, value, value.url)
} }

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.github package org.koitharu.kotatsu.core.github
import java.util.* import org.koitharu.kotatsu.parsers.util.digits
import java.util.Locale
data class VersionId( data class VersionId(
val major: Int, val major: Int,
@@ -43,6 +44,16 @@ val VersionId.isStable: Boolean
get() = variantType.isEmpty() get() = variantType.isEmpty()
fun VersionId(versionName: String): VersionId { 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 parts = versionName.substringBeforeLast('-').split('.')
val variant = versionName.substringAfterLast('-', "") val variant = versionName.substringAfterLast('-', "")
return VersionId( 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.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder import android.graphics.BitmapRegionDecoder
import android.graphics.Rect import android.graphics.Rect
import android.os.Build import android.os.Build
import androidx.core.graphics.drawable.toDrawable import coil3.Extras
import coil.ImageLoader import coil3.ImageLoader
import coil.decode.DecodeResult import coil3.asImage
import coil.decode.DecodeUtils import coil3.decode.DecodeResult
import coil.decode.Decoder import coil3.decode.DecodeUtils
import coil.decode.ImageSource import coil3.decode.Decoder
import coil.fetch.SourceResult import coil3.decode.ImageSource
import coil.request.Options import coil3.fetch.SourceFetchResult
import coil.size.Dimension import coil3.getExtra
import coil.size.Scale import coil3.request.Options
import coil.size.Size import coil3.request.allowRgb565
import coil.size.isOriginal import coil3.request.bitmapConfig
import coil.size.pxOrElse 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.runInterruptible
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlin.math.roundToInt import kotlin.math.roundToInt
class RegionBitmapDecoder( class RegionBitmapDecoder(
private val source: ImageSource, private val source: ImageSource,
private val options: Options, private val options: Options,
private val parallelismLock: Semaphore,
) : Decoder { ) : Decoder {
override suspend fun decode() = parallelismLock.withPermit { override suspend fun decode(): DecodeResult = runInterruptible {
runInterruptible { BitmapFactory.Options().decode() }
}
private fun BitmapFactory.Options.decode(): DecodeResult {
val regionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val regionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BitmapRegionDecoder.newInstance(source.source().inputStream()) BitmapRegionDecoder.newInstance(source.source().inputStream())
} else { } else {
@@ -41,13 +41,14 @@ class RegionBitmapDecoder(
BitmapRegionDecoder.newInstance(source.source().inputStream(), false) BitmapRegionDecoder.newInstance(source.source().inputStream(), false)
} }
checkNotNull(regionDecoder) checkNotNull(regionDecoder)
val bitmapOptions = BitmapFactory.Options()
try { try {
val rect = configureScale(regionDecoder.width, regionDecoder.height) val rect = bitmapOptions.configureScale(regionDecoder.width, regionDecoder.height)
configureConfig() bitmapOptions.configureConfig()
val bitmap = regionDecoder.decodeRegion(rect, this) val bitmap = regionDecoder.decodeRegion(rect, bitmapOptions)
bitmap.density = options.context.resources.displayMetrics.densityDpi bitmap.density = options.context.resources.displayMetrics.densityDpi
return DecodeResult( DecodeResult(
drawable = bitmap.toDrawable(options.context.resources), image = bitmap.asImage(),
isSampled = true, isSampled = true,
) )
} finally { } 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]. */ /** Compute and set the scaling properties for [BitmapFactory.Options]. */
private fun BitmapFactory.Options.configureScale(srcWidth: Int, srcHeight: Int): Rect { private fun BitmapFactory.Options.configureScale(srcWidth: Int, srcHeight: Int): Rect {
val dstWidth = options.size.widthPx(options.scale) { srcWidth } val dstWidth = options.size.widthPx(options.scale) { srcWidth }
@@ -91,7 +69,7 @@ class RegionBitmapDecoder(
} else { } else {
Rect(0, 0, (srcHeight / dstRatio).toInt().coerceAtLeast(1), srcHeight) 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) { if (scroll == SCROLL_UNDEFINED) {
rect.offsetTo( rect.offsetTo(
(srcWidth - rect.width()) / 2, (srcWidth - rect.width()) / 2,
@@ -123,7 +101,7 @@ class RegionBitmapDecoder(
) )
// Only upscale the image if the options require an exact size. // Only upscale the image if the options require an exact size.
if (options.allowInexactSize) { if (options.precision == Precision.INEXACT) {
scale = scale.coerceAtMost(1.0) scale = scale.coerceAtMost(1.0)
} }
@@ -142,19 +120,36 @@ class RegionBitmapDecoder(
return rect return rect
} }
class Factory( private fun BitmapFactory.Options.configureConfig() {
maxParallelism: Int = DEFAULT_MAX_PARALLELISM, var config = options.bitmapConfig
) : Decoder.Factory {
@Suppress("NEWER_VERSION_IN_SINCE_KOTLIN") inMutable = false
@SinceKotlin("999.9") // Only public in Java.
constructor() : this()
private val parallelismLock = Semaphore(maxParallelism) if (Build.VERSION.SDK_INT >= 26 && options.colorSpace != null) {
inPreferredColorSpace = options.colorSpace
override fun create(result: SourceResult, options: Options, imageLoader: ImageLoader): Decoder {
return RegionBitmapDecoder(result.source, options, parallelismLock)
} }
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 override fun equals(other: Any?) = other is Factory
@@ -163,9 +158,8 @@ class RegionBitmapDecoder(
companion object { companion object {
const val PARAM_SCROLL = "scroll"
const val SCROLL_UNDEFINED = -1 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 { private inline fun Size.widthPx(scale: Scale, original: () -> Int): Int {
return if (isOriginal) original() else width.toPx(scale) 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

@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaState 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 org.koitharu.kotatsu.parsers.util.mapToSet
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@@ -29,8 +29,6 @@ fun Collection<Manga>.distinctById() = distinctBy { it.id }
@JvmName("chaptersIds") @JvmName("chaptersIds")
fun Collection<MangaChapter>.ids() = mapToSet { it.id } fun Collection<MangaChapter>.ids() = mapToSet { it.id }
fun Collection<MangaChapter>.findById(id: Long) = find { x -> x.id == id }
fun Collection<ChapterListItem>.countChaptersByBranch(): Int { fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) { if (size <= 1) {
return size return size
@@ -84,10 +82,6 @@ val Demographic.titleResId: Int
Demographic.NONE -> R.string.none Demographic.NONE -> R.string.none
} }
fun Manga.findChapter(id: Long): MangaChapter? {
return chapters?.findById(id)
}
fun Manga.getPreferredBranch(history: MangaHistory?): String? { fun Manga.getPreferredBranch(history: MangaHistory?): String? {
val ch = chapters val ch = chapters
if (ch.isNullOrEmpty()) { if (ch.isNullOrEmpty()) {
@@ -136,12 +130,6 @@ val Manga.appUrl: Uri
.appendQueryParameter("url", url) .appendQueryParameter("url", url)
.build() .build()
fun MangaChapter.formatNumber(): String? = if (number > 0f) {
number.formatSimple()
} else {
null
}
fun Manga.chaptersCount(): Int { fun Manga.chaptersCount(): Int {
if (chapters.isNullOrEmpty()) { if (chapters.isNullOrEmpty()) {
return 0 return 0

View File

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

View File

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

View File

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

View File

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

View File

@@ -85,7 +85,7 @@ class DoHManager(
).build() ).build()
DoHProvider.ZERO_MS -> DnsOverHttps.Builder().client(bootstrapClient) 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) .resolvePublicAddresses(true)
.build() .build()
} }

View File

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

View File

@@ -5,7 +5,7 @@ import androidx.annotation.WorkerThread
import androidx.core.util.Predicate import androidx.core.util.Predicate
import okhttp3.Cookie import okhttp3.Cookie
import okhttp3.HttpUrl import okhttp3.HttpUrl
import org.koitharu.kotatsu.core.util.ext.newBuilder import org.koitharu.kotatsu.parsers.util.newBuilder
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
package org.koitharu.kotatsu.core.network.imageproxy package org.koitharu.kotatsu.core.network.imageproxy
import coil.intercept.Interceptor import coil3.intercept.Interceptor
import coil.request.ImageResult import coil3.request.ImageResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -26,7 +26,7 @@ class RealImageProxyInterceptor @Inject constructor(
) )
override suspend fun intercept(chain: Interceptor.Chain): ImageResult { 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 { override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {

View File

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

View File

@@ -1,6 +1,6 @@
package org.koitharu.kotatsu.core.network.imageproxy package org.koitharu.kotatsu.core.network.imageproxy
import coil.request.ImageRequest import coil3.request.ImageRequest
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request 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.IconCompat
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.room.InvalidationTracker import androidx.room.InvalidationTracker
import coil.ImageLoader import coil3.ImageLoader
import coil.request.ImageRequest import coil3.request.ImageRequest
import coil.size.Scale import coil3.request.transformations
import coil.size.Size import coil3.size.Scale
import coil3.size.Size
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job 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.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -138,7 +139,7 @@ class AppShortcutManager @Inject constructor(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(manga.coverUrl) .data(manga.coverUrl)
.size(iconSize) .size(iconSize)
.source(manga.source) .mangaSourceExtra(manga.source)
.scale(Scale.FILL) .scale(Scale.FILL)
.transformations(ThumbnailTransformation()) .transformations(ThumbnailTransformation())
.build(), .build(),

View File

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

View File

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

View File

@@ -1,35 +1,34 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import android.net.Uri import android.net.Uri
import coil.request.CachePolicy import coil3.request.CachePolicy
import dagger.Reusable import dagger.Reusable
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.almostEquals import org.koitharu.kotatsu.parsers.util.almostEquals
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.toRelativeUrl
import javax.inject.Inject import javax.inject.Inject
@Reusable @Reusable
class MangaLinkResolver @Inject constructor( class MangaLinkResolver @Inject constructor(
private val repositoryFactory: MangaRepository.Factory, private val repositoryFactory: MangaRepository.Factory,
private val sourcesRepository: MangaSourcesRepository,
private val dataRepository: MangaDataRepository, private val dataRepository: MangaDataRepository,
private val context: MangaLoaderContext,
) { ) {
suspend fun resolve(uri: Uri): Manga { suspend fun resolve(uri: Uri): Manga {
return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") { return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") {
resolveAppLink(uri) resolveAppLink(uri)
} else { } else {
resolveExternalLink(uri) resolveExternalLink(uri.toString())
} ?: throw NotFoundException("Cannot resolve link", uri.toString()) } ?: throw NotFoundException("Cannot resolve link", uri.toString())
} }
@@ -45,18 +44,11 @@ class MangaLinkResolver @Inject constructor(
) )
} }
private suspend fun resolveExternalLink(uri: Uri): Manga? { private suspend fun resolveExternalLink(uri: String): Manga? {
dataRepository.findMangaByPublicUrl(uri.toString())?.let { dataRepository.findMangaByPublicUrl(uri)?.let {
return it return it
} }
val host = uri.host ?: return null return context.newLinkResolver(uri).getManga()
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)
} }
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? { private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
@@ -85,12 +77,10 @@ class MangaLinkResolver @Inject constructor(
}.getOrThrow() }.getOrThrow()
} }
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga { private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga = if (this is CachingMangaRepository) {
return if (this is ParserMangaRepository) { getDetails(manga, CachePolicy.READ_ONLY)
getDetails(manga, CachePolicy.READ_ONLY) } else {
} else { getDetails(manga)
getDetails(manga)
}
} }
private fun getSeedManga(source: MangaSource, url: String, title: String?) = Manga( private fun getSeedManga(source: MangaSource, url: String, title: String?) = Manga(

View File

@@ -2,12 +2,10 @@ package org.koitharu.kotatsu.core.parser
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory
import android.util.Base64 import android.util.Base64
import android.webkit.WebView import android.webkit.WebView
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -17,6 +15,7 @@ import okhttp3.OkHttpClient
import okhttp3.Response import okhttp3.Response
import okhttp3.ResponseBody.Companion.asResponseBody import okhttp3.ResponseBody.Companion.asResponseBody
import okio.Buffer import okio.Buffer
import org.koitharu.kotatsu.core.image.BitmapDecoderCompat
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
@@ -31,7 +30,6 @@ import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.UserAgents import org.koitharu.kotatsu.parsers.network.UserAgents
import org.koitharu.kotatsu.parsers.util.map import org.koitharu.kotatsu.parsers.util.map
import org.koitharu.kotatsu.parsers.util.mimeType
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
@@ -80,15 +78,13 @@ class MangaLoaderContextImpl @Inject constructor(
override fun redrawImageResponse(response: Response, redraw: (image: Bitmap) -> Bitmap): Response { override fun redrawImageResponse(response: Response, redraw: (image: Bitmap) -> Bitmap): Response {
return response.map { body -> return response.map { body ->
val opts = BitmapFactory.Options() BitmapDecoderCompat.decode(body.byteStream(), body.contentType(), isMutable = true).use { bitmap ->
opts.inMutable = true
BitmapFactory.decodeStream(body.byteStream(), null, opts)?.use { bitmap ->
(redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper).use { result -> (redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper).use { result ->
Buffer().also { Buffer().also {
result.compressTo(it.outputStream()) result.compressTo(it.outputStream())
}.asResponseBody("image/jpeg".toMediaType()) }.asResponseBody("image/jpeg".toMediaType())
} }
} ?: throw ImageDecodeException(response.request.url.toString(), response.mimeType) }
} }
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import kotlinx.coroutines.Dispatchers
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
@@ -17,9 +18,9 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
class ParserMangaRepository( class ParserMangaRepository(
private val parser: MangaParser, private val parser: MangaParser,
@@ -27,7 +28,7 @@ class ParserMangaRepository(
cache: MemoryContentCache, cache: MemoryContentCache,
) : CachingMangaRepository(cache), Interceptor { ) : CachingMangaRepository(cache), Interceptor {
private val filterOptionsLazy = SuspendLazy { private val filterOptionsLazy = suspendLazy(Dispatchers.Default) {
mirrorSwitchInterceptor.withMirrorSwitching { mirrorSwitchInterceptor.withMirrorSwitching {
parser.getFilterOptions() parser.getFilterOptions()
} }
@@ -78,7 +79,9 @@ class ParserMangaRepository(
} }
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching { 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 getFilterOptions(): MangaListFilterOptions = filterOptionsLazy.get() override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptionsLazy.get()

View File

@@ -13,7 +13,7 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import java.util.EnumSet import java.util.EnumSet
class ExternalMangaRepository( class ExternalMangaRepository(
@@ -32,7 +32,7 @@ class ExternalMangaRepository(
}.getOrNull() }.getOrNull()
} }
private val filterOptions = SuspendLazy(contentSource::getListFilterOptions) private val filterOptions = suspendLazy(initializer = contentSource::getListFilterOptions)
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.POPULARITY) get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.POPULARITY)
@@ -42,7 +42,7 @@ class ExternalMangaRepository(
override var defaultSortOrder: SortOrder override var defaultSortOrder: SortOrder
get() = capabilities?.availableSortOrders?.firstOrNull() ?: SortOrder.ALPHABETICAL get() = capabilities?.availableSortOrders?.firstOrNull() ?: SortOrder.ALPHABETICAL
set(value) = Unit set(_) = Unit
override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptions.get() override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptions.get()

View File

@@ -7,7 +7,6 @@ import androidx.collection.ArraySet
import androidx.core.net.toUri import androidx.core.net.toUri
import org.jetbrains.annotations.Blocking import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Demographic import org.koitharu.kotatsu.parsers.model.Demographic
@@ -21,6 +20,7 @@ import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.splitTwoParts import org.koitharu.kotatsu.parsers.util.splitTwoParts
import java.util.EnumSet import java.util.EnumSet
@@ -81,7 +81,7 @@ class ExternalPluginContentSource(
publicUrl = details.publicUrl.ifEmpty { manga.publicUrl }, publicUrl = details.publicUrl.ifEmpty { manga.publicUrl },
rating = maxOf(details.rating, manga.rating), rating = maxOf(details.rating, manga.rating),
isNsfw = details.isNsfw, isNsfw = details.isNsfw,
coverUrl = details.coverUrl.ifEmpty { manga.coverUrl }, coverUrl = details.coverUrl.ifNullOrEmpty { manga.coverUrl },
tags = details.tags + manga.tags, tags = details.tags + manga.tags,
state = details.state ?: manga.state, state = details.state ?: manga.state,
author = details.author.ifNullOrEmpty { manga.author }, author = details.author.ifNullOrEmpty { manga.author },
@@ -141,7 +141,7 @@ class ExternalPluginContentSource(
@Blocking @Blocking
@WorkerThread @WorkerThread
fun getPageUrl(url: String): String { fun getPageUrl(url: String): String {
val uri = "content://${source.authority}/pages/0".toUri().buildUpon() val uri = "content://${source.authority}/manga/pages/0".toUri().buildUpon()
.appendQueryParameter("url", url) .appendQueryParameter("url", url)
.build() .build()
return contentResolver.query(uri, null, null, null, null) return contentResolver.query(uri, null, null, null, null)

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.parser.favicon package org.koitharu.kotatsu.core.parser.favicon
import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.AdaptiveIconDrawable import android.graphics.drawable.AdaptiveIconDrawable
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
@@ -8,232 +7,130 @@ import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable import android.graphics.drawable.LayerDrawable
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.webkit.MimeTypeMap import coil3.ImageLoader
import coil.ImageLoader import coil3.asImage
import coil.decode.DataSource import coil3.decode.DataSource
import coil.decode.ImageSource import coil3.fetch.FetchResult
import coil.disk.DiskCache import coil3.fetch.Fetcher
import coil.fetch.DrawableResult import coil3.fetch.ImageFetchResult
import coil.fetch.FetchResult import coil3.request.Options
import coil.fetch.Fetcher import coil3.size.pxOrElse
import coil.fetch.SourceResult import coil3.toAndroidUri
import coil.network.HttpException
import coil.request.Options
import coil.size.Size
import coil.size.pxOrElse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient import okio.IOException
import okhttp3.Request import org.koitharu.kotatsu.R
import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.internal.closeQuietly
import okio.Closeable
import okio.buffer
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.EmptyMangaRepository import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.ext.fetch
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.requireBody
import java.net.HttpURLConnection
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
import coil3.Uri as CoilUri
private const val FALLBACK_SIZE = 9999 // largest icon
class FaviconFetcher( class FaviconFetcher(
private val okHttpClient: OkHttpClient, private val uri: Uri,
private val diskCache: Lazy<DiskCache?>,
private val mangaSource: MangaSource,
private val options: Options, private val options: Options,
private val imageLoader: ImageLoader,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
) : Fetcher { ) : Fetcher {
private val diskCacheKey override suspend fun fetch(): FetchResult? {
get() = options.diskCacheKey ?: "${mangaSource.name}x${options.size.toCacheKey()}" 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)) { return when (val repo = mangaRepositoryFactory.create(mangaSource)) {
is ParserMangaRepository -> fetchParserFavicon(repo) is ParserMangaRepository -> fetchParserFavicon(repo)
is ExternalMangaRepository -> fetchPluginIcon(repo) is ExternalMangaRepository -> fetchPluginIcon(repo)
is EmptyMangaRepository -> DrawableResult( is EmptyMangaRepository -> ImageFetchResult(
drawable = ColorDrawable(Color.WHITE), image = ColorDrawable(Color.WHITE).asImage(),
isSampled = false, isSampled = false,
dataSource = DataSource.MEMORY, 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( val sizePx = maxOf(
options.size.width.pxOrElse { FALLBACK_SIZE }, options.size.width.pxOrElse { FALLBACK_SIZE },
options.size.height.pxOrElse { FALLBACK_SIZE }, options.size.height.pxOrElse { FALLBACK_SIZE },
) )
var favicons = repo.getFavicons() var favicons = repository.getFavicons()
var lastError: Exception? = null var lastError: Exception? = null
while (favicons.isNotEmpty()) { while (favicons.isNotEmpty()) {
coroutineContext.ensureActive() coroutineContext.ensureActive()
val icon = favicons.find(sizePx) ?: throwNSEE(lastError) val icon = favicons.find(sizePx) ?: throwNSEE(lastError)
val response = try { try {
loadIcon(icon.url, mangaSource) val result = imageLoader.fetch(icon.url, options)
if (result != null) {
return result
} else {
favicons -= icon
}
} catch (e: CloudFlareProtectedException) { } catch (e: CloudFlareProtectedException) {
throw e throw e
} catch (e: HttpException) { } catch (e: IOException) {
lastError = e lastError = e
favicons -= icon 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) throwNSEE(lastError)
} }
private suspend fun loadIcon(url: String, source: MangaSource): Response {
val request = Request.Builder()
.url(url)
.get()
.tag(MangaSource::class.java, source)
request.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 { private suspend fun fetchPluginIcon(repository: ExternalMangaRepository): FetchResult {
val source = repository.source val source = repository.source
val pm = options.context.packageManager val pm = options.context.packageManager
val icon = runInterruptible(Dispatchers.IO) { val icon = runInterruptible {
val provider = pm.resolveContentProvider(source.authority, 0) val provider = pm.resolveContentProvider(source.authority, 0)
provider?.loadIcon(pm) ?: pm.getApplicationIcon(source.packageName) provider?.loadIcon(pm) ?: pm.getApplicationIcon(source.packageName)
} }
return DrawableResult( return ImageFetchResult(
drawable = icon.nonAdaptive(), image = icon.nonAdaptive().asImage(),
isSampled = false, isSampled = false,
dataSource = DataSource.DISK, 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( class Factory(
context: Context,
okHttpClientLazy: Lazy<OkHttpClient>,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
) : Fetcher.Factory<Uri> { ) : Fetcher.Factory<CoilUri> {
private val okHttpClient by okHttpClientLazy override fun create(
private val diskCache = lazy { data: CoilUri,
val rootDir = context.externalCacheDir ?: context.cacheDir options: Options,
DiskCache.Builder() imageLoader: ImageLoader
.directory(rootDir.resolve(CacheDir.FAVICONS.dir)) ): Fetcher? = if (data.scheme == URI_SCHEME_FAVICON) {
.build() FaviconFetcher(data.toAndroidUri(), options, imageLoader, mangaRepositoryFactory)
} } else {
null
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
}
} }
} }
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.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.ActivityInfo
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Uri import android.net.Uri
import android.os.Build 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.find
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import java.io.File import java.io.File
import java.net.Proxy import java.net.Proxy
import java.util.EnumSet import java.util.EnumSet
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -119,6 +122,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false) get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) } 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 val isReaderVolumeButtonsEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_VOLUME_BUTTONS, false) get() = prefs.getBoolean(KEY_READER_VOLUME_BUTTONS, false)
@@ -134,10 +141,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderOptimizationEnabled: Boolean val isReaderOptimizationEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false) 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 val isOfflineCheckDisabled: Boolean
get() = prefs.getBoolean(KEY_OFFLINE_DISABLED, false) get() = prefs.getBoolean(KEY_OFFLINE_DISABLED, false)
@@ -328,8 +331,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
} }
} }
val isDownloadsWiFiOnly: Boolean var allowDownloadOnMeteredNetwork: TriStateOption
get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false) get() = prefs.getEnumValue(KEY_DOWNLOADS_METERED_NETWORK, TriStateOption.ASK)
set(value) = prefs.edit { putEnumValue(KEY_DOWNLOADS_METERED_NETWORK, value) }
val preferredDownloadFormat: DownloadFormat val preferredDownloadFormat: DownloadFormat
get() = prefs.getEnumValue(KEY_DOWNLOADS_FORMAT, DownloadFormat.AUTOMATIC) 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 get() = prefs.getString(KEY_PROXY_PORT, null)?.toIntOrNull() ?: 0
val proxyLogin: String? val proxyLogin: String?
get() = prefs.getString(KEY_PROXY_LOGIN, null)?.takeUnless { it.isEmpty() } get() = prefs.getString(KEY_PROXY_LOGIN, null)?.nullIfEmpty()
val proxyPassword: String? val proxyPassword: String?
get() = prefs.getString(KEY_PROXY_PASSWORD, null)?.takeUnless { it.isEmpty() } get() = prefs.getString(KEY_PROXY_PASSWORD, null)?.nullIfEmpty()
var localListOrder: SortOrder var localListOrder: SortOrder
get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST) get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST)
@@ -471,7 +475,17 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val periodicalBackupFrequency: Long val periodicalBackupFrequency: Long
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L 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() get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull()
set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) } set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) }
@@ -573,7 +587,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_THEME = "theme" const val KEY_THEME = "theme"
const val KEY_COLOR_THEME = "color_theme" const val KEY_COLOR_THEME = "color_theme"
const val KEY_THEME_AMOLED = "amoled_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_OFFLINE_DISABLED = "no_offline"
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear" const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear" const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear"
@@ -592,6 +605,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr" const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
const val KEY_READER_FULLSCREEN = "reader_fullscreen" const val KEY_READER_FULLSCREEN = "reader_fullscreen"
const val KEY_READER_VOLUME_BUTTONS = "reader_volume_buttons" 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_ENABLED = "tracker_enabled"
const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi" const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi"
const val KEY_TRACKER_FREQUENCY = "tracker_freq" const val KEY_TRACKER_FREQUENCY = "tracker_freq"
@@ -619,6 +633,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_RESTORE = "restore" const val KEY_RESTORE = "restore"
const val KEY_BACKUP_PERIODICAL_ENABLED = "backup_periodic" const val KEY_BACKUP_PERIODICAL_ENABLED = "backup_periodic"
const val KEY_BACKUP_PERIODICAL_FREQUENCY = "backup_periodic_freq" 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_OUTPUT = "backup_periodic_output"
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last" const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
const val KEY_HISTORY_GROUPING = "history_grouping" const val KEY_HISTORY_GROUPING = "history_grouping"
@@ -639,7 +655,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_ANILIST = "anilist" const val KEY_ANILIST = "anilist"
const val KEY_MAL = "mal" const val KEY_MAL = "mal"
const val KEY_KITSU = "kitsu" const val KEY_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_DOWNLOADS_FORMAT = "downloads_format"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
const val KEY_DOH = "doh" const val KEY_DOH = "doh"
@@ -710,7 +726,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LINK_TELEGRAM = "about_telegram" const val KEY_LINK_TELEGRAM = "about_telegram"
const val KEY_LINK_GITHUB = "about_github" const val KEY_LINK_GITHUB = "about_github"
const val KEY_LINK_MANUAL = "about_help" const val KEY_LINK_MANUAL = "about_help"
const val PROXY_TEST = "proxy_test" 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 // old keys are for migration only
private const val KEY_IMAGES_PROXY_OLD = "images_proxy" private const val KEY_IMAGES_PROXY_OLD = "images_proxy"

View File

@@ -4,13 +4,14 @@ import android.content.Context
import android.content.SharedPreferences.OnSharedPreferenceChangeListener import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import androidx.core.content.edit import androidx.core.content.edit
import org.koitharu.kotatsu.core.util.ext.getEnumValue import org.koitharu.kotatsu.core.util.ext.getEnumValue
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.settings.utils.validation.DomainValidator import org.koitharu.kotatsu.settings.utils.validation.DomainValidator
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig { class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
@@ -38,7 +39,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue) is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue) is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
is ConfigKey.PreferredImageServer -> prefs.getString(key.key, key.defaultValue)?.takeUnless(String::isEmpty) is ConfigKey.PreferredImageServer -> prefs.getString(key.key, key.defaultValue)?.nullIfEmpty()
} as T } as T
} }

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
@Keep
enum class TriStateOption {
ENABLED, ASK, DISABLED;
}

View File

@@ -112,9 +112,13 @@ abstract class BaseActivity<B : ViewBinding> :
} }
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { if (BuildConfig.DEBUG) {
ActivityCompat.recreate(this) if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
return true ActivityCompat.recreate(this)
return true
} else if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
throw RuntimeException("Test crash")
}
} }
return super.onKeyDown(keyCode, event) return super.onKeyDown(keyCode, event)
} }

View File

@@ -9,7 +9,10 @@ import androidx.annotation.CallSuper
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceScreen
import androidx.preference.get
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@@ -20,9 +23,11 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
import org.koitharu.kotatsu.core.util.ext.parentView import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint @AndroidEntryPoint
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
@@ -67,6 +72,10 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
setTitle(if (titleId != 0) getString(titleId) else null) setTitle(if (titleId != 0) getString(titleId) else null)
arguments?.getString(SettingsActivity.ARG_PREF_KEY)?.let {
focusPreference(it)
arguments?.remove(SettingsActivity.ARG_PREF_KEY)
}
} }
@CallSuper @CallSuper
@@ -87,4 +96,31 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
false false
} }
private fun focusPreference(key: String) {
val pref = findPreference<Preference>(key)
if (pref == null) {
scrollToPreference(key)
return
}
scrollToPreference(pref)
val prefIndex = preferenceScreen.indexOf(key)
val view = if (prefIndex >= 0) {
listView.findViewHolderForAdapterPosition(prefIndex)?.itemView ?: return
} else {
return
}
view.context.getThemeDrawable(materialR.attr.colorTertiaryContainer)?.let {
view.background = it
}
}
private fun PreferenceScreen.indexOf(key: String): Int {
for (i in 0 until preferenceCount) {
if (get(i).key == key) {
return i
}
}
return -1
}
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.ui package org.koitharu.kotatsu.core.ui
import android.app.Notification
import android.app.PendingIntent import android.app.PendingIntent
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
@@ -9,11 +10,10 @@ import android.os.PatternMatcher
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.core.app.PendingIntentCompat import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -21,60 +21,111 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import kotlin.coroutines.CoroutineContext
abstract class CoroutineIntentService : BaseService() { abstract class CoroutineIntentService : BaseService() {
private val mutex = Mutex() private val mutex = Mutex()
protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default
final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
val job = launchCoroutine(intent, startId) launchCoroutine(intent, startId)
val receiver = CancelReceiver(job)
ContextCompat.registerReceiver(
this,
receiver,
createIntentFilter(this, startId),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
job.invokeOnCompletion { unregisterReceiver(receiver) }
return START_REDELIVER_INTENT return START_REDELIVER_INTENT
} }
private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch(errorHandler(startId)) { private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch {
val intentJobContext = IntentJobContextImpl(startId, coroutineContext)
mutex.withLock { mutex.withLock {
try { try {
if (intent != null) { if (intent != null) {
withContext(dispatcher) { withContext(Dispatchers.Default) {
processIntent(startId, intent) intentJobContext.processIntent(intent)
} }
} }
} catch (e: Throwable) { } catch (e: Throwable) {
e.printStackTraceDebug() e.printStackTraceDebug()
onError(startId, e) intentJobContext.onError(e)
} finally { } finally {
stopSelf(startId) intentJobContext.stop()
} }
} }
} }
@WorkerThread @WorkerThread
protected abstract suspend fun processIntent(startId: Int, intent: Intent) protected abstract suspend fun IntentJobContext.processIntent(intent: Intent)
@AnyThread @AnyThread
protected abstract fun onError(startId: Int, error: Throwable) protected abstract fun IntentJobContext.onError(error: Throwable)
protected fun getCancelIntent(startId: Int) = PendingIntentCompat.getBroadcast( interface IntentJobContext {
this,
0,
createCancelIntent(this, startId),
PendingIntent.FLAG_UPDATE_CURRENT,
false,
)
private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable -> val startId: Int
throwable.printStackTraceDebug()
onError(startId, throwable) fun getCancelIntent(): PendingIntent?
fun setForeground(id: Int, notification: Notification, serviceType: Int)
}
protected inner class IntentJobContextImpl(
override val startId: Int,
private val coroutineContext: CoroutineContext,
) : IntentJobContext {
private var cancelReceiver: CancelReceiver? = null
private var isStopped = false
private var isForeground = false
override fun getCancelIntent(): PendingIntent? {
ensureHasCancelReceiver()
return PendingIntentCompat.getBroadcast(
applicationContext,
0,
createCancelIntent(this@CoroutineIntentService, startId),
PendingIntent.FLAG_UPDATE_CURRENT,
false,
)
}
override fun setForeground(id: Int, notification: Notification, serviceType: Int) {
ServiceCompat.startForeground(this@CoroutineIntentService, id, notification, serviceType)
isForeground = true
}
fun stop() {
synchronized(this) {
cancelReceiver?.let {
try {
unregisterReceiver(it)
} catch (e: IllegalArgumentException) {
e.printStackTraceDebug()
}
}
isStopped = true
}
if (isForeground) {
ServiceCompat.stopForeground(this@CoroutineIntentService, ServiceCompat.STOP_FOREGROUND_REMOVE)
}
stopSelf(startId)
}
private fun ensureHasCancelReceiver() {
if (cancelReceiver == null && !isStopped) {
synchronized(this) {
if (cancelReceiver == null && !isStopped) {
val job = coroutineContext[Job] ?: return
CancelReceiver(job).let { receiver ->
ContextCompat.registerReceiver(
applicationContext,
receiver,
createIntentFilter(this@CoroutineIntentService, startId),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
cancelReceiver = receiver
}
}
}
}
}
} }
private class CancelReceiver( private class CancelReceiver(

View File

@@ -6,12 +6,13 @@ import android.view.LayoutInflater
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.databinding.DialogTwoButtonsBinding import org.koitharu.kotatsu.databinding.DialogTwoButtonsBinding
class TwoButtonsAlertDialog private constructor( class BigButtonsAlertDialog private constructor(
private val delegate: AlertDialog private val delegate: AlertDialog
) : DialogInterface by delegate { ) : DialogInterface by delegate {
@@ -51,14 +52,44 @@ class TwoButtonsAlertDialog private constructor(
@StringRes textId: Int, @StringRes textId: Int,
listener: DialogInterface.OnClickListener? = null listener: DialogInterface.OnClickListener? = null
): Builder { ): Builder {
initButton(binding.button2, DialogInterface.BUTTON_NEGATIVE, textId, listener) initButton(binding.button3, DialogInterface.BUTTON_NEGATIVE, textId, listener)
return this return this
} }
fun create(): TwoButtonsAlertDialog { fun setNeutralButton(
@StringRes textId: Int,
listener: DialogInterface.OnClickListener? = null
): Builder {
initButton(binding.button2, DialogInterface.BUTTON_NEUTRAL, textId, listener)
return this
}
fun create(): BigButtonsAlertDialog {
with(binding) {
button1.adjustCorners(isFirst = true, isLast = button2.isGone && button3.isGone)
button2.adjustCorners(isFirst = button1.isGone, isLast = button3.isGone)
button3.adjustCorners(isFirst = button1.isGone && button2.isGone, isLast = true)
}
val dialog = delegate.create() val dialog = delegate.create()
binding.root.tag = dialog binding.root.tag = dialog
return TwoButtonsAlertDialog(dialog) return BigButtonsAlertDialog(dialog)
}
private fun MaterialButton.adjustCorners(isFirst: Boolean, isLast: Boolean) {
if (!isVisible) {
return
}
shapeAppearanceModel = shapeAppearanceModel.toBuilder().apply {
if (!isFirst) {
setTopLeftCornerSize(0f)
setTopRightCornerSize(0f)
}
if (!isLast) {
setBottomLeftCornerSize(0f)
setBottomRightCornerSize(0f)
}
}.build()
} }
private fun initButton( private fun initButton(

View File

@@ -1,25 +1,58 @@
package org.koitharu.kotatsu.core.ui.dialog package org.koitharu.kotatsu.core.ui.dialog
import android.content.Context import android.content.Context
import android.content.DialogInterface
import androidx.annotation.UiContext import androidx.annotation.UiContext
import androidx.core.net.ConnectivityManagerCompat
import dagger.Lazy
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.TriStateOption
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import javax.inject.Inject
object CommonAlertDialogs { class CommonAlertDialogs @Inject constructor(
private val settings: Lazy<AppSettings>,
) {
fun showDownloadConfirmation( fun askForDownloadOverMeteredNetwork(
@UiContext context: Context, @UiContext context: Context,
onConfirmed: (startPaused: Boolean) -> Unit, onConfirmed: (allow: Boolean) -> Unit
) = buildAlertDialog(context, isCentered = true) { ) {
var startPaused = false when (settings.get().allowDownloadOnMeteredNetwork) {
setTitle(R.string.save_manga) TriStateOption.ENABLED -> onConfirmed(true)
setIcon(R.drawable.ic_download) TriStateOption.DISABLED -> onConfirmed(false)
setMessage(R.string.save_manga_confirm) TriStateOption.ASK -> {
setCheckbox(R.string.start_download, true) { _, isChecked -> if (!ConnectivityManagerCompat.isActiveNetworkMetered(context.connectivityManager)) {
startPaused = !isChecked onConfirmed(true)
return
}
val listener = DialogInterface.OnClickListener { _, which ->
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
settings.get().allowDownloadOnMeteredNetwork = TriStateOption.ENABLED
onConfirmed(true)
}
DialogInterface.BUTTON_NEUTRAL -> {
onConfirmed(true)
}
DialogInterface.BUTTON_NEGATIVE -> {
settings.get().allowDownloadOnMeteredNetwork = TriStateOption.DISABLED
onConfirmed(false)
}
}
}
BigButtonsAlertDialog.Builder(context)
.setIcon(R.drawable.ic_network_cellular)
.setTitle(R.string.download_cellular_confirm)
.setPositiveButton(R.string.allow_always, listener)
.setNeutralButton(R.string.allow_once, listener)
.setNegativeButton(R.string.dont_allow, listener)
.create()
.show()
}
} }
setPositiveButton(R.string.save) { _, _ -> }
onConfirmed(startPaused)
}
setNegativeButton(android.R.string.cancel, null)
}.show()
} }

View File

@@ -58,7 +58,7 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
if (exception.isReportable()) { if (exception.isReportable()) {
builder.setPositiveButton(R.string.report) { _, _ -> builder.setPositiveButton(R.string.report) { _, _ ->
dismiss() dismiss()
exception.report() exception.report(silent = true)
} }
} }
return builder return builder

View File

@@ -1,7 +1,7 @@
package org.koitharu.kotatsu.core.ui.image package org.koitharu.kotatsu.core.ui.image
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import coil.target.GenericViewTarget import coil3.target.GenericViewTarget
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
class ChipIconTarget(override val view: Chip) : GenericViewTarget<Chip>() { class ChipIconTarget(override val view: Chip) : GenericViewTarget<Chip>() {

View File

@@ -4,10 +4,12 @@ import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.text.Html import android.text.Html
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import coil.ImageLoader import coil3.ImageLoader
import coil.executeBlocking import coil3.executeBlocking
import coil.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.allowHardware
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.core.util.ext.drawable
import javax.inject.Inject import javax.inject.Inject
class CoilImageGetter @Inject constructor( class CoilImageGetter @Inject constructor(

View File

@@ -4,9 +4,9 @@ import android.view.View
import android.view.View.OnLayoutChangeListener import android.view.View.OnLayoutChangeListener
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import coil.size.Dimension import coil3.size.Dimension
import coil.size.Size import coil3.size.Size
import coil.size.ViewSizeResolver import coil3.size.ViewSizeResolver
import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume import kotlin.coroutines.resume

View File

@@ -2,11 +2,11 @@ package org.koitharu.kotatsu.core.ui.image
import android.graphics.Bitmap import android.graphics.Bitmap
import android.media.ThumbnailUtils import android.media.ThumbnailUtils
import coil.size.Size import coil3.size.Size
import coil.size.pxOrElse import coil3.size.pxOrElse
import coil.transform.Transformation import coil3.transform.Transformation
class ThumbnailTransformation : Transformation { class ThumbnailTransformation : Transformation() {
override val cacheKey: String = javaClass.name override val cacheKey: String = javaClass.name
@@ -17,8 +17,4 @@ class ThumbnailTransformation : Transformation {
size.height.pxOrElse { input.height }, size.height.pxOrElse { input.height },
) )
} }
override fun equals(other: Any?) = other is ThumbnailTransformation
override fun hashCode() = javaClass.hashCode()
} }

View File

@@ -2,13 +2,13 @@ package org.koitharu.kotatsu.core.ui.image
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.core.graphics.get import androidx.core.graphics.get
import coil.size.Size import coil3.size.Size
import coil.transform.Transformation import coil3.transform.Transformation
import org.koitharu.kotatsu.reader.domain.EdgeDetector.Companion.isColorTheSame import org.koitharu.kotatsu.reader.domain.EdgeDetector.Companion.isColorTheSame
class TrimTransformation( class TrimTransformation(
private val tolerance: Int = 20, private val tolerance: Int = 20,
) : Transformation { ) : Transformation() {
override val cacheKey: String = "${javaClass.name}-$tolerance" override val cacheKey: String = "${javaClass.name}-$tolerance"
@@ -92,12 +92,4 @@ class TrimTransformation(
input input
} }
} }
override fun equals(other: Any?): Boolean {
return this === other || (other is TrimTransformation && other.tolerance == tolerance)
}
override fun hashCode(): Int {
return tolerance
}
} }

View File

@@ -28,6 +28,8 @@ class AdapterDelegateClickListenerAdapter<I, O>(
private fun mappedItem(): O = itemMapper.apply(adapterDelegate.item) private fun mappedItem(): O = itemMapper.apply(adapterDelegate.item)
fun attach() = attach(adapterDelegate.itemView)
fun attach(itemView: View) { fun attach(itemView: View) {
itemView.setOnClickListener(this) itemView.setOnClickListener(this)
itemView.setOnLongClickListener(this) itemView.setOnLongClickListener(this)

View File

@@ -18,9 +18,8 @@ abstract class LifecycleAwareViewHolder(
private var isCurrent = false private var isCurrent = false
init { init {
parentLifecycleOwner.lifecycle.addObserver(ParentLifecycleObserver()) itemView.post {
if (parentLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) { parentLifecycleOwner.lifecycle.addObserver(ParentLifecycleObserver())
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
} }
} }
@@ -29,6 +28,9 @@ abstract class LifecycleAwareViewHolder(
dispatchResumed() dispatchResumed()
} }
@CallSuper
open fun onCreate() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
@CallSuper @CallSuper
open fun onStart() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START) open fun onStart() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START)
@@ -41,6 +43,9 @@ abstract class LifecycleAwareViewHolder(
@CallSuper @CallSuper
open fun onStop() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP) open fun onStop() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
@CallSuper
open fun onDestroy() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
private fun dispatchResumed() { private fun dispatchResumed() {
val isParentResumed = parentLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) val isParentResumed = parentLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
if (isCurrent && isParentResumed) { if (isCurrent && isParentResumed) {
@@ -60,28 +65,18 @@ abstract class LifecycleAwareViewHolder(
private inner class ParentLifecycleObserver : DefaultLifecycleObserver { private inner class ParentLifecycleObserver : DefaultLifecycleObserver {
override fun onCreate(owner: LifecycleOwner) { override fun onCreate(owner: LifecycleOwner) = this@LifecycleAwareViewHolder.onCreate()
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
}
override fun onStart(owner: LifecycleOwner) { override fun onStart(owner: LifecycleOwner) = this@LifecycleAwareViewHolder.onStart()
onStart()
}
override fun onResume(owner: LifecycleOwner) { override fun onResume(owner: LifecycleOwner) = this@LifecycleAwareViewHolder.dispatchResumed()
dispatchResumed()
}
override fun onPause(owner: LifecycleOwner) { override fun onPause(owner: LifecycleOwner) = this@LifecycleAwareViewHolder.dispatchResumed()
dispatchResumed()
}
override fun onStop(owner: LifecycleOwner) { override fun onStop(owner: LifecycleOwner) = this@LifecycleAwareViewHolder.onStop()
onStop()
}
override fun onDestroy(owner: LifecycleOwner) { override fun onDestroy(owner: LifecycleOwner) {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) this@LifecycleAwareViewHolder.onDestroy()
owner.lifecycle.removeObserver(this) owner.lifecycle.removeObserver(this)
} }
} }

View File

@@ -8,10 +8,16 @@ import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.view.children import androidx.core.view.children
import coil.ImageLoader import coil3.ImageLoader
import coil.request.Disposable import coil3.request.Disposable
import coil.request.ImageRequest import coil3.request.ImageRequest
import coil.transform.RoundedCornersTransformation import coil3.request.allowRgb565
import coil3.request.crossfade
import coil3.request.error
import coil3.request.fallback
import coil3.request.placeholder
import coil3.request.transformations
import coil3.transform.RoundedCornersTransformation
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipDrawable
import com.google.android.material.chip.ChipGroup import com.google.android.material.chip.ChipGroup

View File

@@ -11,11 +11,13 @@ import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.shapes.RoundRectShape import android.graphics.drawable.shapes.RoundRectShape
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.widget.Checkable
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes import androidx.core.content.withStyledAttributes
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.widget.ImageViewCompat import androidx.core.widget.ImageViewCompat
import androidx.core.widget.TextViewCompat import androidx.core.widget.TextViewCompat
@@ -23,6 +25,7 @@ import com.google.android.material.ripple.RippleUtils
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.shape.ShapeAppearanceModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getDrawableCompat
import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ViewTwoLinesItemBinding import org.koitharu.kotatsu.databinding.ViewTwoLinesItemBinding
@@ -32,7 +35,7 @@ class TwoLinesItemView @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0, @AttrRes defStyleAttr: Int = 0,
) : LinearLayout(context, attrs, defStyleAttr) { ) : LinearLayout(context, attrs, defStyleAttr), Checkable {
private val binding = ViewTwoLinesItemBinding.inflate(LayoutInflater.from(context), this) private val binding = ViewTwoLinesItemBinding.inflate(LayoutInflater.from(context), this)
@@ -48,6 +51,12 @@ class TwoLinesItemView @JvmOverloads constructor(
binding.subtitle.textAndVisible = value binding.subtitle.textAndVisible = value
} }
var isButtonEnabled: Boolean
get() = binding.button.isEnabled
set(value) {
binding.button.isEnabled = value
}
init { init {
var textColors: ColorStateList? = null var textColors: ColorStateList? = null
context.withStyledAttributes( context.withStyledAttributes(
@@ -68,7 +77,7 @@ class TwoLinesItemView @JvmOverloads constructor(
binding.layoutText.updateLayoutParams<MarginLayoutParams> { marginStart = drawablePadding } binding.layoutText.updateLayoutParams<MarginLayoutParams> { marginStart = drawablePadding }
setIconResource(getResourceId(R.styleable.TwoLinesItemView_icon, 0)) setIconResource(getResourceId(R.styleable.TwoLinesItemView_icon, 0))
binding.title.text = getText(R.styleable.TwoLinesItemView_title) binding.title.text = getText(R.styleable.TwoLinesItemView_title)
binding.subtitle.text = getText(R.styleable.TwoLinesItemView_subtitle) binding.subtitle.textAndVisible = getText(R.styleable.TwoLinesItemView_subtitle)
textColors = getColorStateList(R.styleable.TwoLinesItemView_android_textColor) textColors = getColorStateList(R.styleable.TwoLinesItemView_android_textColor)
val textAppearanceFallback = androidx.appcompat.R.style.TextAppearance_AppCompat val textAppearanceFallback = androidx.appcompat.R.style.TextAppearance_AppCompat
TextViewCompat.setTextAppearance( TextViewCompat.setTextAppearance(
@@ -79,6 +88,10 @@ class TwoLinesItemView @JvmOverloads constructor(
binding.subtitle, binding.subtitle,
getResourceId(R.styleable.TwoLinesItemView_subtitleTextAppearance, textAppearanceFallback), getResourceId(R.styleable.TwoLinesItemView_subtitleTextAppearance, textAppearanceFallback),
) )
binding.icon.isChecked = getBoolean(R.styleable.TwoLinesItemView_android_checked, false)
val button = getDrawableCompat(context, R.styleable.TwoLinesItemView_android_button)
binding.button.setImageDrawable(button)
binding.button.isVisible = button != null
} }
if (textColors == null) { if (textColors == null) {
textColors = binding.title.textColors textColors = binding.title.textColors
@@ -88,6 +101,16 @@ class TwoLinesItemView @JvmOverloads constructor(
ImageViewCompat.setImageTintList(binding.icon, textColors) ImageViewCompat.setImageTintList(binding.icon, textColors)
} }
override fun isChecked() = binding.icon.isChecked
override fun toggle() = binding.icon.toggle()
override fun setChecked(checked: Boolean) {
binding.icon.isChecked = checked
}
fun setOnButtonClickListener(listener: OnClickListener?) = binding.button.setOnClickListener(listener)
fun setIconResource(@DrawableRes resId: Int) { fun setIconResource(@DrawableRes resId: Int) {
binding.icon.setImageResource(resId) binding.icon.setImageResource(resId)
} }

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.util package org.koitharu.kotatsu.core.util
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.core.app.ShareCompat import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
@@ -75,11 +76,9 @@ class ShareHelper(private val context: Context) {
.startChooser() .startChooser()
} }
fun shareText(text: String) { fun getShareTextIntent(text: String): Intent = ShareCompat.IntentBuilder(context)
ShareCompat.IntentBuilder(context) .setText(text)
.setText(text) .setType(TYPE_TEXT)
.setType(TYPE_TEXT) .setChooserTitle(R.string.share)
.setChooserTitle(R.string.share) .createChooserIntent()
.startChooser()
}
} }

View File

@@ -14,12 +14,17 @@ import androidx.lifecycle.SavedStateHandle
import java.io.Serializable import java.io.Serializable
import java.util.EnumSet import java.util.EnumSet
// https://issuetracker.google.com/issues/240585930 // https://issuetracker.google.com/issues/240585930
inline fun <reified T : Parcelable> Bundle.getParcelableCompat(key: String): T? { inline fun <reified T : Parcelable> Bundle.getParcelableCompat(key: String): T? {
return BundleCompat.getParcelable(this, key, T::class.java) return BundleCompat.getParcelable(this, key, T::class.java)
} }
inline fun <reified T : Parcelable> Bundle.requireParcelable(key: String): T = checkNotNull(getParcelableCompat(key)) {
"Parcelable of type \"${T::class.java.name}\" not found at \"$key\""
}
inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String): T? { inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String): T? {
return IntentCompat.getParcelableExtra(this, key, T::class.java) return IntentCompat.getParcelableExtra(this, key, T::class.java)
} }
@@ -84,3 +89,24 @@ fun <T> SavedStateHandle.require(key: String): T {
"Value $key not found in SavedStateHandle or has a wrong type" "Value $key not found in SavedStateHandle or has a wrong type"
} }
} }
fun Parcelable.marshall(): ByteArray {
val parcel = Parcel.obtain()
return try {
this.writeToParcel(parcel, 0)
parcel.marshall()
} finally {
parcel.recycle()
}
}
fun <T : Parcelable> Parcelable.Creator<T>.unmarshall(bytes: ByteArray): T {
val parcel = Parcel.obtain()
return try {
parcel.unmarshall(bytes, 0, bytes.size)
parcel.setDataPosition(0)
createFromParcel(parcel)
} finally {
parcel.recycle()
}
}

View File

@@ -2,21 +2,37 @@ package org.koitharu.kotatsu.core.util.ext
import android.content.Context import android.content.Context
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.widget.ImageView import android.widget.ImageView
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil3.Extras
import coil.request.ErrorResult import coil3.ImageLoader
import coil.request.ImageRequest import coil3.asDrawable
import coil.request.ImageResult import coil3.fetch.FetchResult
import coil.request.SuccessResult import coil3.request.ErrorResult
import coil.util.CoilUtils import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.Options
import coil3.request.SuccessResult
import coil3.request.bitmapConfig
import coil3.request.crossfade
import coil3.request.error
import coil3.request.fallback
import coil3.request.lifecycle
import coil3.request.placeholder
import coil3.request.target
import coil3.size.Scale
import coil3.size.ViewSizeResolver
import coil3.toBitmap
import coil3.util.CoilUtils
import com.google.android.material.progressindicator.BaseProgressIndicator import com.google.android.material.progressindicator.BaseProgressIndicator
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.image.RegionBitmapDecoder
import org.koitharu.kotatsu.core.ui.image.AnimatedPlaceholderDrawable import org.koitharu.kotatsu.core.ui.image.AnimatedPlaceholderDrawable
import org.koitharu.kotatsu.core.ui.image.RegionBitmapDecoder
import org.koitharu.kotatsu.core.util.progress.ImageRequestIndicatorListener import org.koitharu.kotatsu.core.util.progress.ImageRequestIndicatorListener
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@@ -32,6 +48,8 @@ fun ImageView.newImageRequest(lifecycleOwner: LifecycleOwner, data: Any?): Image
.data(data?.takeUnless { it == "" || it == 0 }) .data(data?.takeUnless { it == "" || it == 0 })
.lifecycle(lifecycleOwner) .lifecycle(lifecycleOwner)
.crossfade(context) .crossfade(context)
.size(ViewSizeResolver(this))
.scale(scaleType.toCoilScale())
.target(this) .target(this)
} }
@@ -43,13 +61,16 @@ fun ImageView.disposeImageRequest() {
fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build()) fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build())
fun ImageResult.getDrawableOrThrow() = when (this) { fun ImageResult.getDrawableOrThrow() = when (this) {
is SuccessResult -> drawable is SuccessResult -> image.asDrawable(request.context.resources)
is ErrorResult -> throw throwable is ErrorResult -> throw throwable
} }
val ImageResult.drawable: Drawable?
get() = image?.asDrawable(request.context.resources)
fun ImageResult.toBitmapOrNull() = when (this) { fun ImageResult.toBitmapOrNull() = when (this) {
is SuccessResult -> try { is SuccessResult -> try {
drawable.toBitmap() image.toBitmap(image.width, image.height, request.bitmapConfig)
} catch (_: Throwable) { } catch (_: Throwable) {
null null
} }
@@ -63,8 +84,10 @@ fun ImageRequest.Builder.indicator(indicators: List<BaseProgressIndicator<*>>):
fun ImageRequest.Builder.decodeRegion( fun ImageRequest.Builder.decodeRegion(
scroll: Int = RegionBitmapDecoder.SCROLL_UNDEFINED, scroll: Int = RegionBitmapDecoder.SCROLL_UNDEFINED,
): ImageRequest.Builder = decoderFactory(RegionBitmapDecoder.Factory()) ): ImageRequest.Builder = apply {
.setParameter(RegionBitmapDecoder.PARAM_SCROLL, scroll) decoderFactory(RegionBitmapDecoder.Factory)
extras[RegionBitmapDecoder.regionScrollKey] = scroll
}
@Suppress("SpellCheckingInspection") @Suppress("SpellCheckingInspection")
fun ImageRequest.Builder.crossfade(context: Context): ImageRequest.Builder { fun ImageRequest.Builder.crossfade(context: Context): ImageRequest.Builder {
@@ -72,8 +95,18 @@ fun ImageRequest.Builder.crossfade(context: Context): ImageRequest.Builder {
return crossfade(duration.toInt()) return crossfade(duration.toInt())
} }
fun ImageRequest.Builder.source(source: MangaSource?): ImageRequest.Builder { fun ImageRequest.Builder.mangaSourceExtra(source: MangaSource?): ImageRequest.Builder = apply {
return tag(MangaSource::class.java, source) extras[mangaSourceKey] = source
}
fun ImageRequest.Builder.mangaExtra(manga: Manga): ImageRequest.Builder = apply {
extras[mangaKey] = manga
mangaSourceExtra(manga.source)
}
fun ImageRequest.Builder.bookmarkExtra(bookmark: Bookmark): ImageRequest.Builder = apply {
extras[bookmarkKey] = bookmark
mangaSourceExtra(bookmark.manga.source)
} }
fun ImageRequest.Builder.defaultPlaceholders(context: Context): ImageRequest.Builder { fun ImageRequest.Builder.defaultPlaceholders(context: Context): ImageRequest.Builder {
@@ -87,6 +120,12 @@ fun ImageRequest.Builder.defaultPlaceholders(context: Context): ImageRequest.Bui
.error(ColorDrawable(errorColor)) .error(ColorDrawable(errorColor))
} }
private fun ImageView.ScaleType.toCoilScale(): Scale = if (this == ImageView.ScaleType.CENTER_CROP) {
Scale.FILL
} else {
Scale.FIT
}
fun ImageRequest.Builder.addListener(listener: ImageRequest.Listener): ImageRequest.Builder { fun ImageRequest.Builder.addListener(listener: ImageRequest.Listener): ImageRequest.Builder {
val existing = build().listener val existing = build().listener
return listener( return listener(
@@ -98,6 +137,12 @@ fun ImageRequest.Builder.addListener(listener: ImageRequest.Listener): ImageRequ
) )
} }
suspend fun ImageLoader.fetch(data: Any, options: Options): FetchResult? {
val mappedData = components.map(data, options)
val fetcher = components.newFetcher(mappedData, options, this)?.first
return fetcher?.fetch()
}
private class CompositeImageRequestListener( private class CompositeImageRequestListener(
private val delegates: Array<ImageRequest.Listener>, private val delegates: Array<ImageRequest.Listener>,
) : ImageRequest.Listener { ) : ImageRequest.Listener {
@@ -113,3 +158,7 @@ private class CompositeImageRequestListener(
operator fun plus(other: ImageRequest.Listener) = CompositeImageRequestListener(delegates + other) operator fun plus(other: ImageRequest.Listener) = CompositeImageRequestListener(delegates + other)
} }
val mangaKey = Extras.Key<Manga?>(null)
val bookmarkKey = Extras.Key<Bookmark?>(null)
val mangaSourceKey = Extras.Key<MangaSource?>(null)

View File

@@ -4,21 +4,8 @@ import androidx.collection.ArrayMap
import androidx.collection.ArraySet import androidx.collection.ArraySet
import androidx.collection.LongSet import androidx.collection.LongSet
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import java.util.Collections
import java.util.EnumSet import java.util.EnumSet
inline fun <T> MutableSet(size: Int, init: (index: Int) -> T): MutableSet<T> {
val set = ArraySet<T>(size)
repeat(size) { index -> set.add(init(index)) }
return set
}
inline fun <T> Set(size: Int, init: (index: Int) -> T): Set<T> = when (size) {
0 -> emptySet()
1 -> Collections.singleton(init(0))
else -> MutableSet(size, init)
}
fun <T> Collection<T>.asArrayList(): ArrayList<T> = if (this is ArrayList<*>) { fun <T> Collection<T>.asArrayList(): ArrayList<T> = if (this is ArrayList<*>) {
this as ArrayList<T> this as ArrayList<T>
} else { } else {
@@ -76,15 +63,6 @@ fun <T> Iterable<T>.sortedWithSafe(comparator: Comparator<in T>): List<T> = try
} }
} }
fun Collection<*>?.sizeOrZero() = this?.size ?: 0
@Suppress("UNCHECKED_CAST")
inline fun <T, reified R> Collection<T>.mapToArray(transform: (T) -> R): Array<R> {
val result = arrayOfNulls<R>(size)
forEachIndexed { index, t -> result[index] = transform(t) }
return result as Array<R>
}
fun LongSet.toLongArray(): LongArray { fun LongSet.toLongArray(): LongArray {
val result = LongArray(size) val result = LongArray(size)
var i = 0 var i = 0

View File

@@ -6,6 +6,7 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.provider.DocumentsContract import android.provider.DocumentsContract
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.parsers.util.removeSuffix import org.koitharu.kotatsu.parsers.util.removeSuffix
import java.io.File import java.io.File
import java.lang.reflect.Array as ArrayReflect import java.lang.reflect.Array as ArrayReflect
@@ -80,7 +81,7 @@ private fun getVolumePathForAndroid11AndAbove(volumeId: String, context: Context
private fun getVolumeIdFromTreeUri(treeUri: Uri): String? { private fun getVolumeIdFromTreeUri(treeUri: Uri): String? {
val docId = DocumentsContract.getTreeDocumentId(treeUri) val docId = DocumentsContract.getTreeDocumentId(treeUri)
val split = docId.split(":".toRegex()) val split = docId.split(":".toRegex())
return split.firstOrNull()?.takeUnless { it.isEmpty() } return split.firstOrNull()?.nullIfEmpty()
} }
private fun getDocumentPathFromTreeUri(treeUri: Uri): String? { private fun getDocumentPathFromTreeUri(treeUri: Uri): String? {

View File

@@ -7,15 +7,17 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okhttp3.internal.closeQuietly import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import org.jetbrains.annotations.Blocking import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.fs.FileSequence import org.koitharu.kotatsu.core.fs.FileSequence
import java.io.BufferedReader
import java.io.File import java.io.File
import java.io.InputStream
import java.nio.file.attribute.BasicFileAttributes import java.nio.file.attribute.BasicFileAttributes
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
@@ -35,17 +37,15 @@ fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() }
fun File.isNotEmpty() = length() != 0L fun File.isNotEmpty() = length() != 0L
@Blocking @Blocking
fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().use { fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).use { output ->
it.readText() output.bufferedReader().use(BufferedReader::readText)
} }
@Blocking val ZipEntry.mimeType: MediaType?
fun ZipFile.getInputStreamOrClose(entry: ZipEntry): InputStream = try { get() {
getInputStream(entry) val ext = name.substringAfterLast('.')
} catch (e: Throwable) { return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)?.toMediaTypeOrNull()
closeQuietly() }
throw e
}
fun File.getStorageName(context: Context): String = runCatching { fun File.getStorageName(context: Context): String = runCatching {
val manager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager val manager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
@@ -61,7 +61,7 @@ fun File.getStorageName(context: Context): String = runCatching {
} }
}.getOrNull() ?: context.getString(R.string.other_storage) }.getOrNull() ?: context.getString(R.string.other_storage)
fun Uri.toFileOrNull() = if (scheme == URI_SCHEME_FILE) path?.let(::File) else null fun Uri.toFileOrNull() = if (isFileUri()) path?.let(::File) else null
suspend fun File.deleteAwait() = runInterruptible(Dispatchers.IO) { suspend fun File.deleteAwait() = runInterruptible(Dispatchers.IO) {
delete() || deleteRecursively() delete() || deleteRecursively()

View File

@@ -17,7 +17,8 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.flow.transformWhile
import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.suspendlazy.SuspendLazy
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
@@ -133,4 +134,4 @@ suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x !
fun <T> Flow<Flow<T>>.flattenLatest() = flatMapLatest { it } fun <T> Flow<Flow<T>>.flattenLatest() = flatMapLatest { it }
fun <T> SuspendLazy<T>.asFlow() = flow { emit(tryGet()) } fun <T> SuspendLazy<T>.asFlow() = flow { emit(runCatchingCancellable { get() }) }

View File

@@ -1,7 +1,5 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import okhttp3.Cookie
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
@@ -27,12 +25,6 @@ fun Response.parseJsonOrNull(): JSONObject? {
} }
} }
val HttpUrl.isHttpOrHttps: Boolean
get() {
val s = scheme.lowercase()
return s == "https" || s == "http"
}
fun Response.ensureSuccess() = apply { fun Response.ensureSuccess() = apply {
if (!isSuccessful || code == HttpURLConnection.HTTP_NO_CONTENT) { if (!isSuccessful || code == HttpURLConnection.HTTP_NO_CONTENT) {
closeQuietly() closeQuietly()
@@ -40,26 +32,6 @@ fun Response.ensureSuccess() = apply {
} }
} }
fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
c.name(name)
c.value(value)
if (persistent) {
c.expiresAt(expiresAt)
}
if (hostOnly) {
c.hostOnlyDomain(domain)
} else {
c.domain(domain)
}
c.path(path)
if (secure) {
c.secure()
}
if (httpOnly) {
c.httpOnly()
}
}
fun String.sanitizeHeaderValue(): String { fun String.sanitizeHeaderValue(): String {
return if (all(Char::isValidForHeaderValue)) { return if (all(Char::isValidForHeaderValue)) {
this // fast path this // fast path

View File

@@ -7,9 +7,15 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.ResponseBody import okhttp3.ResponseBody
import okio.BufferedSink import okio.BufferedSink
import okio.FileSystem
import okio.IOException
import okio.Path
import okio.Source import okio.Source
import org.koitharu.kotatsu.core.util.CancellableSource import org.koitharu.kotatsu.core.util.CancellableSource
import org.koitharu.kotatsu.core.util.progress.ProgressResponseBody import org.koitharu.kotatsu.core.util.progress.ProgressResponseBody
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.nio.ByteBuffer
fun ResponseBody.withProgress(progressState: MutableStateFlow<Float>): ResponseBody { fun ResponseBody.withProgress(progressState: MutableStateFlow<Float>): ResponseBody {
return ProgressResponseBody(this, progressState) return ProgressResponseBody(this, progressState)
@@ -23,3 +29,22 @@ suspend fun Source.cancellable(): Source {
suspend fun BufferedSink.writeAllCancellable(source: Source) = withContext(Dispatchers.IO) { suspend fun BufferedSink.writeAllCancellable(source: Source) = withContext(Dispatchers.IO) {
writeAll(source.cancellable()) writeAll(source.cancellable())
} }
fun InputStream.toByteBuffer(): ByteBuffer {
val outStream = ByteArrayOutputStream(available())
copyTo(outStream)
val bytes = outStream.toByteArray()
return ByteBuffer.allocateDirect(bytes.size).put(bytes).position(0) as ByteBuffer
}
fun FileSystem.isDirectory(path: Path) = try {
metadataOrNull(path)?.isDirectory == true
} catch (_: IOException) {
false
}
fun FileSystem.isRegularFile(path: Path) = try {
metadataOrNull(path)?.isRegularFile == true
} catch (_: IOException) {
false
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.util.ext
import android.content.Context import android.content.Context
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.Set
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale import java.util.Locale

View File

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

View File

@@ -37,13 +37,6 @@ val RecyclerView.visibleItemCount: Int
findLastVisibleItemPosition() - findFirstVisibleItemPosition() findLastVisibleItemPosition() - findFirstVisibleItemPosition()
} ?: 0 } ?: 0
fun RecyclerView.findCenterViewPosition(): Int {
val centerX = width / 2f
val centerY = height / 2f
val view = findChildViewUnder(centerX, centerY) ?: return RecyclerView.NO_POSITION
return getChildAdapterPosition(view)
}
fun <T> RecyclerView.ViewHolder.getItem(clazz: Class<T>): T? { fun <T> RecyclerView.ViewHolder.getItem(clazz: Class<T>): T? {
val rawItem = when (this) { val rawItem = when (this) {
is AdapterDelegateViewBindingViewHolder<*, *> -> item is AdapterDelegateViewBindingViewHolder<*, *> -> item

View File

@@ -2,25 +2,11 @@ package org.koitharu.kotatsu.core.util.ext
import android.content.Context import android.content.Context
import android.database.DatabaseUtils import android.database.DatabaseUtils
import androidx.annotation.FloatRange import androidx.collection.arraySetOf
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.ellipsize import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import java.util.UUID import java.util.UUID
inline fun <C : CharSequence?> C?.ifNullOrEmpty(defaultValue: () -> C): C {
return if (this.isNullOrEmpty()) defaultValue() else this
}
fun String.longHashCode(): Long {
var h = 1125899906842597L
val len: Int = this.length
for (i in 0 until len) {
h = 31 * h + this[i].code
}
return h
}
fun String.toUUIDOrNull(): UUID? = try { fun String.toUUIDOrNull(): UUID? = try {
UUID.fromString(this) UUID.fromString(this)
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
@@ -28,17 +14,35 @@ fun String.toUUIDOrNull(): UUID? = try {
null null
} }
/** fun String.transliterate(skipMissing: Boolean): String {
* @param threshold 0 = exact match val cyr = charArrayOf(
*/ 'а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'й', 'к', 'л', 'м', 'н', 'о', 'п',
fun String.almostEquals(other: String, @FloatRange(from = 0.0) threshold: Float): Boolean { 'р', 'с', 'т', 'у', 'ф', 'х', 'ц', 'ч', 'ш', 'щ', 'ъ', 'ы', 'ь', 'э', 'ю', 'я', 'ё', 'ў',
if (threshold == 0f) { )
return equals(other, ignoreCase = true) val lat = arrayOf(
"a", "b", "v", "g", "d", "e", "zh", "z", "i", "y", "k", "l", "m", "n", "o", "p",
"r", "s", "t", "u", "f", "h", "ts", "ch", "sh", "sch", "", "i", "", "e", "ju", "ja", "jo", "w",
)
return buildString(length + 5) {
for (c in this@transliterate) {
val p = cyr.binarySearch(c.lowercaseChar())
if (p in lat.indices) {
if (c.isUpperCase()) {
append(lat[p].uppercase())
} else {
append(lat[p])
}
} else if (!skipMissing) {
append(c)
}
}
} }
val diff = lowercase().levenshteinDistance(other.lowercase()) / ((length + other.length) / 2f)
return diff < threshold
} }
fun String.toFileNameSafe(): String = this.transliterate(false)
.replace(Regex("[^a-z0-9_\\-]", arraySetOf(RegexOption.IGNORE_CASE)), " ")
.replace(Regex("\\s+"), "_")
fun CharSequence.sanitize(): CharSequence { fun CharSequence.sanitize(): CharSequence {
return filterNot { c -> c.isReplacement() } return filterNot { c -> c.isReplacement() }
} }
@@ -66,10 +70,11 @@ fun <T> Collection<T>.joinToStringWithLimit(context: Context, limit: Int, transf
} }
} }
@Deprecated("", @Deprecated(
"",
ReplaceWith( ReplaceWith(
"sqlEscapeString(this)", "sqlEscapeString(this)",
"android.database.DatabaseUtils.sqlEscapeString" "android.database.DatabaseUtils.sqlEscapeString",
) ),
) )
fun String.sqlEscape(): String = DatabaseUtils.sqlEscapeString(this) fun String.sqlEscape(): String = DatabaseUtils.sqlEscapeString(this)

View File

@@ -3,11 +3,13 @@ package org.koitharu.kotatsu.core.util.ext
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.res.Resources import android.content.res.Resources
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import coil.network.HttpException import coil3.network.HttpException
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import okhttp3.Response
import okio.FileNotFoundException import okio.FileNotFoundException
import okio.IOException import okio.IOException
import okio.ProtocolException import okio.ProtocolException
import org.acra.ktx.sendSilentlyWithAcra
import org.acra.ktx.sendWithAcra import org.acra.ktx.sendWithAcra
import org.jsoup.HttpStatusException import org.jsoup.HttpStatusException
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -22,8 +24,10 @@ import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.exceptions.SyncApiException import org.koitharu.kotatsu.core.exceptions.SyncApiException
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.exceptions.WrapperIOException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.io.NullOutputStream
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED
@@ -34,21 +38,33 @@ import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
import java.io.ObjectOutputStream
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.SocketException
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.net.UnknownHostException import java.net.UnknownHostException
import java.util.Locale
private const val MSG_NO_SPACE_LEFT = "No space left on device" private const val MSG_NO_SPACE_LEFT = "No space left on device"
private const val MSG_CONNECTION_RESET = "Connection reset"
private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported" private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) { fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessageOrNull(resources)
?: resources.getString(R.string.error_occurred)
private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) {
is CaughtException -> cause.getDisplayMessageOrNull(resources)
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
is ScrobblerAuthRequiredException -> resources.getString( is ScrobblerAuthRequiredException -> resources.getString(
R.string.scrobbler_auth_required, R.string.scrobbler_auth_required,
resources.getString(scrobbler.titleResId), resources.getString(scrobbler.titleResId),
) )
is AuthRequiredException -> resources.getString(R.string.auth_required) is AuthRequiredException -> resources.getString(R.string.auth_required)
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required) is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message)
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message) is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
is ActivityNotFoundException, is ActivityNotFoundException,
is UnsupportedOperationException, is UnsupportedOperationException,
@@ -78,16 +94,28 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is ContentUnavailableException -> message is ContentUnavailableException -> message
is ParseException -> shortMessage is ParseException -> shortMessage
is ConnectException,
is UnknownHostException, is UnknownHostException,
is NoRouteToHostException,
is SocketTimeoutException -> resources.getString(R.string.network_error) is SocketTimeoutException -> resources.getString(R.string.network_error)
is ImageDecodeException -> resources.getString( is ImageDecodeException -> {
R.string.error_image_format, val type = format?.substringBefore('/')
format.ifNullOrEmpty { resources.getString(R.string.unknown) }, val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) }
) if (type.isNullOrEmpty() || type == "image") {
resources.getString(R.string.error_image_format, formatString)
} else {
resources.getString(R.string.error_not_image, formatString)
}
}
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received) is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
is IncompatiblePluginException -> resources.getString(R.string.plugin_incompatible) is IncompatiblePluginException -> {
cause?.getDisplayMessageOrNull(resources)?.let {
resources.getString(R.string.plugin_incompatible_with_cause, it)
} ?: resources.getString(R.string.plugin_incompatible)
}
is WrongPasswordException -> resources.getString(R.string.wrong_password) is WrongPasswordException -> resources.getString(R.string.wrong_password)
is NotFoundException -> resources.getString(R.string.not_found_404) is NotFoundException -> resources.getString(R.string.not_found_404)
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source) is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
@@ -95,10 +123,8 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is HttpException -> getHttpDisplayMessage(response.code, resources) is HttpException -> getHttpDisplayMessage(response.code, resources)
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources) is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
else -> getDisplayMessage(message, resources) ?: message else -> mapDisplayMessage(message, resources) ?: message
}.ifNullOrEmpty { }.takeUnless { it.isNullOrBlank() }
resources.getString(R.string.error_occurred)
}
@DrawableRes @DrawableRes
fun Throwable.getDisplayIcon() = when (this) { fun Throwable.getDisplayIcon() = when (this) {
@@ -106,6 +132,8 @@ fun Throwable.getDisplayIcon() = when (this) {
is CloudFlareProtectedException -> R.drawable.ic_bot_large is CloudFlareProtectedException -> R.drawable.ic_bot_large
is UnknownHostException, is UnknownHostException,
is SocketTimeoutException, is SocketTimeoutException,
is ConnectException,
is NoRouteToHostException,
is ProtocolException -> R.drawable.ic_plug_large is ProtocolException -> R.drawable.ic_plug_large
is CloudFlareBlockedException -> R.drawable.ic_denied_large is CloudFlareBlockedException -> R.drawable.ic_denied_large
@@ -117,24 +145,28 @@ fun Throwable.getCauseUrl(): String? = when (this) {
is ParseException -> url is ParseException -> url
is NotFoundException -> url is NotFoundException -> url
is TooManyRequestExceptions -> url is TooManyRequestExceptions -> url
is CaughtException -> cause?.getCauseUrl() is CaughtException -> cause.getCauseUrl()
is WrapperIOException -> cause.getCauseUrl()
is NoDataReceivedException -> url
is CloudFlareBlockedException -> url is CloudFlareBlockedException -> url
is CloudFlareProtectedException -> url is CloudFlareProtectedException -> url
is HttpStatusException -> url is HttpStatusException -> url
is HttpException -> response.request.url.toString() is HttpException -> (response.delegate as? Response)?.request?.url?.toString()
else -> null else -> null
} }
private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) { private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) {
404 -> resources.getString(R.string.not_found_404) 404 -> resources.getString(R.string.not_found_404)
403 -> resources.getString(R.string.access_denied_403)
in 500..599 -> resources.getString(R.string.server_error, statusCode) in 500..599 -> resources.getString(R.string.server_error, statusCode)
else -> null else -> null
} }
private fun getDisplayMessage(msg: String?, resources: Resources): String? = when { private fun mapDisplayMessage(msg: String?, resources: Resources): String? = when {
msg.isNullOrEmpty() -> null msg.isNullOrEmpty() -> null
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left) msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file) msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
msg == MSG_CONNECTION_RESET -> resources.getString(R.string.error_connection_reset)
msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported) msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported) msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported) msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
@@ -148,7 +180,10 @@ fun Throwable.isReportable(): Boolean {
return true return true
} }
if (this is CaughtException) { if (this is CaughtException) {
return cause?.isReportable() == true return cause.isReportable()
}
if (this is WrapperIOException) {
return cause.isReportable()
} }
if (ExceptionResolver.canResolve(this)) { if (ExceptionResolver.canResolve(this)) {
return false return false
@@ -159,6 +194,9 @@ fun Throwable.isReportable(): Boolean {
|| this is CloudFlareProtectedException || this is CloudFlareProtectedException
|| this is BadBackupFormatException || this is BadBackupFormatException
|| this is WrongPasswordException || this is WrongPasswordException
|| this is TooManyRequestExceptions
|| this is HttpStatusException
|| this is SocketException
) { ) {
return false return false
} }
@@ -169,9 +207,13 @@ fun Throwable.isNetworkError(): Boolean {
return this is UnknownHostException || this is SocketTimeoutException return this is UnknownHostException || this is SocketTimeoutException
} }
fun Throwable.report() { fun Throwable.report(silent: Boolean = false) {
val exception = CaughtException(this, "${javaClass.simpleName}($message)") val exception = CaughtException(this)
exception.sendWithAcra() if (silent) {
exception.sendSilentlyWithAcra()
} else {
exception.sendWithAcra()
}
} }
fun Throwable.isWebViewUnavailable(): Boolean { fun Throwable.isWebViewUnavailable(): Boolean {
@@ -181,3 +223,9 @@ fun Throwable.isWebViewUnavailable(): Boolean {
@Suppress("FunctionName") @Suppress("FunctionName")
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT) fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)
fun Throwable.isSerializable() = runCatching {
val oos = ObjectOutputStream(NullOutputStream())
oos.writeObject(this)
oos.flush()
}.isSuccess

View File

@@ -1,56 +1,38 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import android.net.Uri import android.net.Uri
import androidx.core.net.toFile import androidx.core.net.toUri
import okio.Source import okio.Path
import okio.source
import okio.use
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import java.io.File import java.io.File
import java.util.zip.ZipFile
const val URI_SCHEME_FILE = "file"
const val URI_SCHEME_ZIP = "file+zip" const val URI_SCHEME_ZIP = "file+zip"
private const val URI_SCHEME_FILE = "file"
private const val URI_SCHEME_HTTP = "http"
private const val URI_SCHEME_HTTPS = "https"
private const val URI_SCHEME_LEGACY_CBZ = "cbz"
private const val URI_SCHEME_LEGACY_ZIP = "zip"
@Blocking fun Uri.isZipUri() = scheme.let {
fun Uri.exists(): Boolean = when (scheme) { it == URI_SCHEME_ZIP || it == URI_SCHEME_LEGACY_CBZ || it == URI_SCHEME_LEGACY_ZIP
URI_SCHEME_FILE -> toFile().exists()
URI_SCHEME_ZIP -> {
val file = File(requireNotNull(schemeSpecificPart))
file.exists() && ZipFile(file).use { it.getEntry(fragment) != null }
}
else -> unsupportedUri(this)
} }
@Blocking fun Uri.isFileUri() = scheme == URI_SCHEME_FILE
fun Uri.isTargetNotEmpty(): Boolean = when (scheme) {
URI_SCHEME_FILE -> toFile().isNotEmpty()
URI_SCHEME_ZIP -> {
val file = File(requireNotNull(schemeSpecificPart))
file.exists() && ZipFile(file).use { (it.getEntry(fragment)?.size ?: 0L) != 0L }
}
else -> unsupportedUri(this) fun Uri.isNetworkUri() = scheme.let {
it == URI_SCHEME_HTTP || it == URI_SCHEME_HTTPS
} }
@Blocking fun File.toZipUri(entryPath: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryPath")
fun Uri.source(): Source = when (scheme) {
URI_SCHEME_FILE -> toFile().source()
URI_SCHEME_ZIP -> {
val zip = ZipFile(schemeSpecificPart)
val entry = zip.getEntry(fragment)
zip.getInputStreamOrClose(entry).source().withExtraCloseable(zip)
}
else -> unsupportedUri(this) fun File.toZipUri(entryPath: Path?): Uri =
} toZipUri(entryPath?.toString()?.removePrefix(Path.DIRECTORY_SEPARATOR).orEmpty())
fun File.toZipUri(entryName: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryName")
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this) fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
private fun unsupportedUri(uri: Uri): Nothing { fun File.toUri(fragment: String?): Uri = toUri().run {
throw IllegalArgumentException("Bad uri $uri: only schemes $URI_SCHEME_FILE and $URI_SCHEME_ZIP are supported") if (fragment != null) {
buildUpon().fragment(fragment).build()
} else {
this
}
} }

View File

@@ -6,9 +6,9 @@ import androidx.work.WorkInfo
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkQuery import androidx.work.WorkQuery
import androidx.work.WorkRequest import androidx.work.WorkRequest
import androidx.work.await
import androidx.work.impl.WorkManagerImpl import androidx.work.impl.WorkManagerImpl
import androidx.work.impl.model.WorkSpec import androidx.work.impl.model.WorkSpec
import kotlinx.coroutines.guava.await
import java.util.UUID import java.util.UUID
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@@ -63,7 +63,7 @@ suspend fun WorkManager.awaitWorkInfoById(id: UUID): WorkInfo? {
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
suspend fun WorkManager.awaitUniqueWorkInfoByName(name: String): List<WorkInfo> { suspend fun WorkManager.awaitUniqueWorkInfoByName(name: String): List<WorkInfo> {
return getWorkInfosForUniqueWork(name).await().orEmpty() return getWorkInfosForUniqueWork(name).await()
} }
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")

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