Compare commits

...

144 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
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
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
277 changed files with 3676 additions and 2187 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
/.idea/deviceManager.xml
/.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.
[![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
- **Recommended:** Download and install APK from **[GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest)**. Application has a built-in self-updating feature.
- Get it on **[F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu)**. The F-Droid build may be a bit outdated and some fixes might be missing.
- Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (very unstable, use at your own risk).
### Main Features

View File

@@ -1,3 +1,5 @@
import java.time.LocalDateTime
plugins {
id 'com.android.application'
id 'kotlin-android'
@@ -7,17 +9,24 @@ plugins {
id 'dagger.hilt.android.plugin'
}
def Properties versionProps = getVersionProps()
android {
compileSdk = 35
buildToolsVersion = '35.0.0'
namespace = 'org.koitharu.kotatsu'
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'
minSdk = 21
targetSdk = 35
versionCode = 682
versionName = '7.7-a3'
versionCode = code * 1000 + build
versionName = base + (build == 0 ? '' : '.' + build) + (variant == '' ? '' : '-') + variant
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
@@ -28,6 +37,22 @@ android {
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 {
debug {
applicationIdSuffix = '.debug'
@@ -36,12 +61,25 @@ android {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
nightly {
initWith release
applicationIdSuffix = '.nightly'
}
}
buildFeatures {
viewBinding true
buildConfig true
}
packagingOptions {
resources {
excludes += [
'META-INF/README.md',
'META-INF/NOTICE.md'
]
}
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
main.java.srcDirs += 'src/main/kotlin/'
@@ -64,7 +102,7 @@ android {
}
lint {
abortOnError true
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled'
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat'
}
testOptions {
unitTests.includeAndroidResources true
@@ -73,6 +111,15 @@ android {
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
}
}
applicationVariants.configureEach { variant ->
if (variant.name == 'nightly') {
variant.outputs.each { output ->
def now = LocalDateTime.now()
output.versionCodeOverride = now.format("yyMMdd").toInteger()
output.versionNameOverride = 'N' + now.format("yyyyMMdd")
}
}
}
}
afterEvaluate {
compileDebugKotlin {
@@ -82,88 +129,111 @@ afterEvaluate {
}
}
dependencies {
implementation('com.github.KotatsuApp:kotatsu-parsers:d8cb38a9be') {
def parsersVersion = libs.versions.parsers.get()
if (System.properties.containsKey('parsersVersionOverride')) {
// usage:
// -DparsersVersionOverride=$(curl -s https://api.github.com/repos/kotatsuapp/kotatsu-parsers/commits/master -H "Accept: application/vnd.github.sha" | cut -c -10)
parsersVersion = System.getProperty('parsersVersionOverride')
}
//noinspection UseTomlInstead
implementation("com.github.KotatsuApp:kotatsu-parsers:$parsersVersion") {
exclude group: 'org.json', module: 'json'
}
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.20'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
coreLibraryDesugaring libs.desugar.jdk.libs
implementation libs.kotlin.stdlib
implementation libs.kotlinx.coroutines.android
implementation libs.kotlinx.coroutines.guava
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.activity:activity-ktx:1.9.3'
implementation 'androidx.fragment:fragment-ktx:1.8.4'
implementation 'androidx.transition:transition-ktx:1.5.1'
implementation 'androidx.collection:collection-ktx:1.4.4'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6'
implementation 'androidx.lifecycle:lifecycle-service:2.8.6'
implementation 'androidx.lifecycle:lifecycle-process:2.8.6'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.viewpager2:viewpager2:1.1.0'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.6'
implementation 'androidx.webkit:webkit:1.11.0'
implementation libs.androidx.appcompat
implementation libs.androidx.core
implementation libs.androidx.activity
implementation libs.androidx.fragment
implementation libs.androidx.transition
implementation libs.androidx.collection
implementation libs.lifecycle.viewmodel
implementation libs.lifecycle.service
implementation libs.lifecycle.process
implementation libs.androidx.constraintlayout
implementation libs.androidx.swiperefreshlayout
implementation libs.androidx.recyclerview
implementation libs.androidx.viewpager2
implementation libs.androidx.preference
implementation libs.androidx.biometric
implementation libs.material
implementation libs.androidx.lifecycle.common.java8
implementation libs.androidx.webkit
implementation 'androidx.work:work-runtime:2.9.1'
//noinspection GradleDependency
implementation('com.google.guava:guava:33.2.1-android') {
exclude group: 'com.google.guava', module: 'failureaccess'
exclude group: 'org.checkerframework', module: 'checker-qual'
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
}
implementation libs.androidx.work.runtime
implementation libs.guava
implementation 'androidx.room:room-runtime:2.6.1'
implementation 'androidx.room:room-ktx:2.6.1'
ksp 'androidx.room:room-compiler:2.6.1'
implementation libs.androidx.room.runtime
implementation libs.androidx.room.ktx
ksp libs.androidx.room.compiler
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okhttp3:okhttp-tls:4.12.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
implementation 'com.squareup.okio:okio:3.9.1'
implementation libs.okhttp
implementation libs.okhttp.tls
implementation libs.okhttp.dnsoverhttps
implementation libs.okio
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation libs.adapterdelegates
implementation libs.adapterdelegates.viewbinding
implementation 'com.google.dagger:hilt-android:2.52'
kapt 'com.google.dagger:hilt-compiler:2.52'
implementation 'androidx.hilt:hilt-work:1.2.0'
kapt 'androidx.hilt:hilt-compiler:1.2.0'
implementation libs.hilt.android
kapt libs.hilt.compiler
implementation libs.androidx.hilt.work
kapt libs.androidx.hilt.compiler
implementation 'io.coil-kt.coil3:coil-core:3.0.0-rc01'
implementation 'io.coil-kt.coil3:coil-network-okhttp:3.0.0-rc01'
implementation 'io.coil-kt.coil3:coil-gif:3.0.0-rc01'
implementation 'org.aomedia.avif.android:avif:1.1.1.14d8e3c4'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:d1d10a6975'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'
implementation libs.coil.core
implementation libs.coil.network
implementation libs.coil.gif
implementation libs.coil.svg
implementation libs.avif.decoder
implementation libs.ssiv
implementation libs.disk.lru.cache
implementation libs.markwon
implementation 'ch.acra:acra-http:5.11.4'
implementation 'ch.acra:acra-dialog:5.11.4'
implementation libs.acra.http
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 'com.github.Koitharu:WorkInspector:5778dd1747'
debugImplementation libs.leakcanary.android
debugImplementation libs.workinspector
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20240303'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
testImplementation libs.junit
testImplementation libs.json
testImplementation libs.kotlinx.coroutines.test
androidTestImplementation 'androidx.test:runner:1.6.1'
androidTestImplementation 'androidx.test:rules:1.6.1'
androidTestImplementation 'androidx.test:core-ktx:1.6.1'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
androidTestImplementation libs.androidx.runner
androidTestImplementation libs.androidx.rules
androidTestImplementation libs.androidx.test.core
androidTestImplementation libs.androidx.junit
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
androidTestImplementation libs.kotlinx.coroutines.test
androidTestImplementation 'androidx.room:room-testing:2.6.1'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
androidTestImplementation libs.androidx.room.testing
androidTestImplementation libs.moshi.kotlin
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.52'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.52'
androidTestImplementation libs.hilt.android.testing
kaptAndroidTest libs.hilt.android.compiler
}
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.openjsse.**
-dontwarn com.google.j2objc.annotations.**
-dontwarn coil3.PlatformContext
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
@@ -26,3 +27,4 @@
-keep class org.acra.security.NoKeyStoreFactory { *; }
-keep class org.acra.config.DefaultRetryPolicy { *; }
-keep class org.acra.attachment.DefaultAttachmentProvider { *; }
-keep class org.acra.sender.JobSenderService

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 B

View File

@@ -46,7 +46,7 @@
android:allowBackup="true"
android:backupAgent="org.koitharu.kotatsu.settings.backup.AppBackupAgent"
android:dataExtractionRules="@xml/backup_rules"
android:enableOnBackInvokedCallback="true"
android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled"
android:fullBackupContent="@xml/backup_content"
android:fullBackupOnly="true"
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.sync.ui.SyncAuthActivity"
android:exported="true"
android:label="@string/sync" />
<activity
android:name="org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity"
@@ -266,19 +267,30 @@
tools:node="merge" />
<service
android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService"
android:foregroundServiceType="dataSync" />
android:foregroundServiceType="dataSync"
android:label="@string/local_manga_processing" />
<service
android:name="org.koitharu.kotatsu.local.ui.ImportService"
android:foregroundServiceType="dataSync" />
android:name="org.koitharu.kotatsu.settings.backup.PeriodicalBackupService"
android:foregroundServiceType="dataSync"
android:label="@string/periodic_backups" />
<service
android:name="org.koitharu.kotatsu.alternatives.ui.AutoFixService"
android:foregroundServiceType="dataSync" />
<service android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService" />
android:foregroundServiceType="dataSync"
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
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
android:label="@string/manga_shelf"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<service
android:name="org.koitharu.kotatsu.widget.recent.RecentWidgetService"
android:label="@string/recent_manga"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<service
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthenticatorService"
@@ -315,7 +327,8 @@
</service>
<service
android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService"
android:exported="false" />
android:exported="false"
android:label="@string/prefetch_content" />
<provider
android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider"
@@ -394,7 +407,7 @@
android:value="@bool/com_samsung_android_icon_container_has_icon_container" />
<activity-alias
android:name="org.koitharu.kotatsu.details.ui.DetailsBYLinkActivity"
android:name="org.koitharu.kotatsu.details.ui.DetailsByLinkActivity"
android:exported="true"
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.withPermit
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.almostEquals
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.almostEquals
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject

View File

@@ -48,25 +48,21 @@ class AutoFixService : CoroutineIntentService() {
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))
startForeground(startId)
try {
for (mangaId in ids) {
val result = runCatchingCancellable {
autoFixUseCase.invoke(mangaId)
}
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
val notification = buildNotification(result)
notificationManager.notify(TAG, startId, notification)
}
startForeground(this)
for (mangaId in ids) {
val result = runCatchingCancellable {
autoFixUseCase.invoke(mangaId)
}
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
val notification = buildNotification(result)
notificationManager.notify(TAG, startId, notification)
}
} 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)) {
val notification = runBlocking { buildNotification(Result.failure(error)) }
notificationManager.notify(TAG, startId, notification)
@@ -74,7 +70,7 @@ class AutoFixService : CoroutineIntentService() {
}
@SuppressLint("InlinedApi")
private fun startForeground(startId: Int) {
private fun startForeground(jobContext: IntentJobContext) {
val title = applicationContext.getString(R.string.fixing_manga)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
.setName(title)
@@ -98,12 +94,11 @@ class AutoFixService : CoroutineIntentService() {
.addAction(
materialR.drawable.material_ic_clear_black_24dp,
applicationContext.getString(android.R.string.cancel),
getCancelIntent(startId),
jobContext.getCancelIntent(),
)
.build()
ServiceCompat.startForeground(
this,
jobContext.setForeground(
FOREGROUND_NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,

View File

@@ -29,7 +29,7 @@ class CaptchaNotifier(
return
}
val manager = NotificationManagerCompat.from(context)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(context.getString(R.string.captcha_required))
.setShowBadge(true)
.setVibrationEnabled(false)
@@ -42,9 +42,9 @@ class CaptchaNotifier(
.setData(exception.url.toUri())
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(channel.name)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setDefaults(NotificationCompat.DEFAULT_SOUND)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setDefaults(0)
.setSmallIcon(R.drawable.ic_bot)
.setGroup(GROUP_CAPTCHA)
.setAutoCancel(true)
.setVisibility(

View File

@@ -15,6 +15,7 @@ import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.allowRgb565
import coil3.svg.SvgDecoder
import coil3.util.DebugLogger
import dagger.Binds
import dagger.Module
@@ -126,6 +127,7 @@ interface AppModule {
} else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
add(CbzFetcher.Factory())
add(AvifImageDecoder.Factory())
add(FaviconFetcher.Factory(mangaRepositoryFactory))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
package org.koitharu.kotatsu.core.db.entity
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.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
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.toTitleCase
@@ -49,7 +49,7 @@ fun Manga.toEntity() = MangaEntity(
publicUrl = publicUrl,
source = source.name,
largeCoverUrl = largeCoverUrl,
coverUrl = coverUrl,
coverUrl = coverUrl.orEmpty(),
altTitle = altTitle,
rating = rating,
isNsfw = isNsfw,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ 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
@@ -32,19 +33,21 @@ object BitmapDecoderCompat {
}
@Blocking
fun decode(stream: InputStream, type: MediaType?): Bitmap {
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) {
return checkBitmapNotNull(BitmapFactory.decodeStream(stream), format)
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))
ImageDecoder.decodeBitmap(ImageDecoder.createSource(byteBuffer), DecoderConfigListener(isMutable))
}
}
@@ -74,4 +77,18 @@ object BitmapDecoderCompat {
}
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,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.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.formatSimple
import org.koitharu.kotatsu.parsers.util.findById
import org.koitharu.kotatsu.parsers.util.mapToSet
import com.google.android.material.R as materialR
@@ -29,8 +29,6 @@ fun Collection<Manga>.distinctById() = distinctBy { it.id }
@JvmName("chaptersIds")
fun Collection<MangaChapter>.ids() = mapToSet { it.id }
fun Collection<MangaChapter>.findById(id: Long) = find { x -> x.id == id }
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) {
return size
@@ -84,10 +82,6 @@ val Demographic.titleResId: Int
Demographic.NONE -> R.string.none
}
fun Manga.findChapter(id: Long): MangaChapter? {
return chapters?.findById(id)
}
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
val ch = chapters
if (ch.isNullOrEmpty()) {
@@ -136,12 +130,6 @@ val Manga.appUrl: Uri
.appendQueryParameter("url", url)
.build()
fun MangaChapter.formatNumber(): String? = if (number > 0f) {
number.formatSimple()
} else {
null
}
fun Manga.chaptersCount(): Int {
if (chapters.isNullOrEmpty()) {
return 0

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import androidx.annotation.WorkerThread
import androidx.core.util.Predicate
import okhttp3.Cookie
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.suspendCoroutine

View File

@@ -17,8 +17,8 @@ import org.jsoup.HttpStatusException
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
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.isHttpOrHttps
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.net.HttpURLConnection
import java.util.Collections

View File

@@ -6,13 +6,13 @@ import dagger.Reusable
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.almostEquals
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject

View File

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

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.parser
import kotlinx.coroutines.Dispatchers
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.core.cache.MemoryContentCache
@@ -17,9 +18,9 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
class ParserMangaRepository(
private val parser: MangaParser,
@@ -27,7 +28,7 @@ class ParserMangaRepository(
cache: MemoryContentCache,
) : CachingMangaRepository(cache), Interceptor {
private val filterOptionsLazy = SuspendLazy {
private val filterOptionsLazy = suspendLazy(Dispatchers.Default) {
mirrorSwitchInterceptor.withMirrorSwitching {
parser.getFilterOptions()
}
@@ -78,7 +79,9 @@ class ParserMangaRepository(
}
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPageUrl(page)
parser.getPageUrl(page).also { result ->
check(result.isNotEmpty()) { "Page url is empty" }
}
}
override suspend fun 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.MangaPage
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
class ExternalMangaRepository(
@@ -32,7 +32,7 @@ class ExternalMangaRepository(
}.getOrNull()
}
private val filterOptions = SuspendLazy(contentSource::getListFilterOptions)
private val filterOptions = suspendLazy(initializer = contentSource::getListFilterOptions)
override val sortOrders: Set<SortOrder>
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.POPULARITY)
@@ -42,7 +42,7 @@ class ExternalMangaRepository(
override var defaultSortOrder: SortOrder
get() = capabilities?.availableSortOrders?.firstOrNull() ?: SortOrder.ALPHABETICAL
set(value) = Unit
set(_) = Unit
override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptions.get()

View File

@@ -7,7 +7,6 @@ import androidx.collection.ArraySet
import androidx.core.net.toUri
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
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.SortOrder
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.splitTwoParts
import java.util.EnumSet
@@ -81,7 +81,7 @@ class ExternalPluginContentSource(
publicUrl = details.publicUrl.ifEmpty { manga.publicUrl },
rating = maxOf(details.rating, manga.rating),
isNsfw = details.isNsfw,
coverUrl = details.coverUrl.ifEmpty { manga.coverUrl },
coverUrl = details.coverUrl.ifNullOrEmpty { manga.coverUrl },
tags = details.tags + manga.tags,
state = details.state ?: manga.state,
author = details.author.ifNullOrEmpty { manga.author },

View File

@@ -19,6 +19,7 @@ import coil3.toAndroidUri
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.runInterruptible
import okio.IOException
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
@@ -26,6 +27,7 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.util.ext.fetch
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import kotlin.coroutines.coroutineContext
import coil3.Uri as CoilUri
@@ -36,7 +38,7 @@ class FaviconFetcher(
private val mangaRepositoryFactory: MangaRepository.Factory,
) : Fetcher {
override suspend fun fetch(): FetchResult {
override suspend fun fetch(): FetchResult? {
val mangaSource = MangaSource(uri.schemeSpecificPart)
return when (val repo = mangaRepositoryFactory.create(mangaSource)) {
@@ -48,7 +50,9 @@ class FaviconFetcher(
dataSource = DataSource.MEMORY,
)
else -> throw IllegalArgumentException("")
is LocalMangaRepository -> imageLoader.fetch(R.drawable.ic_storage, options)
else -> throw IllegalArgumentException("Unsupported repo ${repo.javaClass.simpleName}")
}
}

View File

@@ -30,10 +30,12 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import java.io.File
import java.net.Proxy
import java.util.EnumSet
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@@ -411,10 +413,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getString(KEY_PROXY_PORT, null)?.toIntOrNull() ?: 0
val proxyLogin: String?
get() = prefs.getString(KEY_PROXY_LOGIN, null)?.takeUnless { it.isEmpty() }
get() = prefs.getString(KEY_PROXY_LOGIN, null)?.nullIfEmpty()
val proxyPassword: String?
get() = prefs.getString(KEY_PROXY_PASSWORD, null)?.takeUnless { it.isEmpty() }
get() = prefs.getString(KEY_PROXY_PASSWORD, null)?.nullIfEmpty()
var localListOrder: SortOrder
get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST)
@@ -473,7 +475,17 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val periodicalBackupFrequency: Long
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L
var periodicalBackupOutput: Uri?
val periodicalBackupFrequencyMillis: Long
get() = TimeUnit.DAYS.toMillis(periodicalBackupFrequency)
val periodicalBackupMaxCount: Int
get() = if (prefs.getBoolean(KEY_BACKUP_PERIODICAL_TRIM, true)) {
prefs.getInt(KEY_BACKUP_PERIODICAL_COUNT, 10)
} else {
Int.MAX_VALUE
}
var periodicalBackupDirectory: Uri?
get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull()
set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) }
@@ -621,6 +633,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_RESTORE = "restore"
const val KEY_BACKUP_PERIODICAL_ENABLED = "backup_periodic"
const val KEY_BACKUP_PERIODICAL_FREQUENCY = "backup_periodic_freq"
const val KEY_BACKUP_PERIODICAL_TRIM = "backup_periodic_trim"
const val KEY_BACKUP_PERIODICAL_COUNT = "backup_periodic_count"
const val KEY_BACKUP_PERIODICAL_OUTPUT = "backup_periodic_output"
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
const val KEY_HISTORY_GROUPING = "history_grouping"
@@ -714,6 +728,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LINK_MANUAL = "about_help"
const val KEY_PROXY_TEST = "proxy_test"
const val KEY_OPEN_BROWSER = "open_browser"
const val KEY_HANDLE_LINKS = "handle_links"
// old keys are for migration only
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"

View File

@@ -4,13 +4,14 @@ import android.content.Context
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import androidx.core.content.edit
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.sanitizeHeaderValue
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource
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
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.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
}

View File

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

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.ui
import android.app.Notification
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
@@ -9,11 +10,10 @@ import android.os.PatternMatcher
import androidx.annotation.AnyThread
import androidx.annotation.WorkerThread
import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@@ -21,60 +21,111 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import kotlin.coroutines.CoroutineContext
abstract class CoroutineIntentService : BaseService() {
private val mutex = Mutex()
protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default
final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
val job = launchCoroutine(intent, startId)
val receiver = CancelReceiver(job)
ContextCompat.registerReceiver(
this,
receiver,
createIntentFilter(this, startId),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
job.invokeOnCompletion { unregisterReceiver(receiver) }
launchCoroutine(intent, startId)
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 {
try {
if (intent != null) {
withContext(dispatcher) {
processIntent(startId, intent)
withContext(Dispatchers.Default) {
intentJobContext.processIntent(intent)
}
}
} catch (e: Throwable) {
e.printStackTraceDebug()
onError(startId, e)
intentJobContext.onError(e)
} finally {
stopSelf(startId)
intentJobContext.stop()
}
}
}
@WorkerThread
protected abstract suspend fun processIntent(startId: Int, intent: Intent)
protected abstract suspend fun IntentJobContext.processIntent(intent: Intent)
@AnyThread
protected abstract fun onError(startId: Int, error: Throwable)
protected abstract fun IntentJobContext.onError(error: Throwable)
protected fun getCancelIntent(startId: Int) = PendingIntentCompat.getBroadcast(
this,
0,
createCancelIntent(this, startId),
PendingIntent.FLAG_UPDATE_CURRENT,
false,
)
interface IntentJobContext {
private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable ->
throwable.printStackTraceDebug()
onError(startId, throwable)
val startId: Int
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(

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,10 @@ inline fun <reified T : Parcelable> Bundle.getParcelableCompat(key: String): T?
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? {
return IntentCompat.getParcelableExtra(this, key, T::class.java)
}

View File

@@ -4,21 +4,8 @@ import androidx.collection.ArrayMap
import androidx.collection.ArraySet
import androidx.collection.LongSet
import org.koitharu.kotatsu.BuildConfig
import java.util.Collections
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<*>) {
this as ArrayList<T>
} 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 {
val result = LongArray(size)
var i = 0

View File

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

View File

@@ -17,7 +17,8 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformLatest
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.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> 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
import okhttp3.Cookie
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
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 {
if (!isSuccessful || code == HttpURLConnection.HTTP_NO_CONTENT) {
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 {
return if (all(Char::isValidForHeaderValue)) {
this // fast path

View File

@@ -7,6 +7,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
import okhttp3.ResponseBody
import okio.BufferedSink
import okio.FileSystem
import okio.IOException
import okio.Path
import okio.Source
import org.koitharu.kotatsu.core.util.CancellableSource
import org.koitharu.kotatsu.core.util.progress.ProgressResponseBody
@@ -33,3 +36,15 @@ fun InputStream.toByteBuffer(): ByteBuffer {
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 androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.Set
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale

View File

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

@@ -2,25 +2,11 @@ package org.koitharu.kotatsu.core.util.ext
import android.content.Context
import android.database.DatabaseUtils
import androidx.annotation.FloatRange
import androidx.collection.arraySetOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
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 {
UUID.fromString(this)
} catch (e: IllegalArgumentException) {
@@ -28,17 +14,35 @@ fun String.toUUIDOrNull(): UUID? = try {
null
}
/**
* @param threshold 0 = exact match
*/
fun String.almostEquals(other: String, @FloatRange(from = 0.0) threshold: Float): Boolean {
if (threshold == 0f) {
return equals(other, ignoreCase = true)
fun String.transliterate(skipMissing: Boolean): String {
val cyr = charArrayOf(
'а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'й', 'к', 'л', 'м', 'н', 'о', 'п',
'р', 'с', 'т', 'у', 'ф', 'х', 'ц', 'ч', 'ш', 'щ', 'ъ', 'ы', 'ь', 'э', 'ю', 'я', 'ё', 'ў',
)
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 {
return filterNot { c -> c.isReplacement() }
}
@@ -66,10 +70,11 @@ fun <T> Collection<T>.joinToStringWithLimit(context: Context, limit: Int, transf
}
}
@Deprecated("",
@Deprecated(
"",
ReplaceWith(
"sqlEscapeString(this)",
"android.database.DatabaseUtils.sqlEscapeString"
)
"android.database.DatabaseUtils.sqlEscapeString",
),
)
fun String.sqlEscape(): String = DatabaseUtils.sqlEscapeString(this)

View File

@@ -9,6 +9,7 @@ import okhttp3.Response
import okio.FileNotFoundException
import okio.IOException
import okio.ProtocolException
import org.acra.ktx.sendSilentlyWithAcra
import org.acra.ktx.sendWithAcra
import org.jsoup.HttpStatusException
import org.koitharu.kotatsu.R
@@ -23,8 +24,10 @@ import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.exceptions.SyncApiException
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
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.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_STATES_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED
@@ -35,21 +38,33 @@ import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
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.UnknownHostException
import java.util.Locale
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"
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(
R.string.scrobbler_auth_required,
resources.getString(scrobbler.titleResId),
)
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 ActivityNotFoundException,
is UnsupportedOperationException,
@@ -79,16 +94,28 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is ContentUnavailableException -> message
is ParseException -> shortMessage
is ConnectException,
is UnknownHostException,
is NoRouteToHostException,
is SocketTimeoutException -> resources.getString(R.string.network_error)
is ImageDecodeException -> resources.getString(
R.string.error_image_format,
format.ifNullOrEmpty { resources.getString(R.string.unknown) },
)
is ImageDecodeException -> {
val type = format?.substringBefore('/')
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 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 NotFoundException -> resources.getString(R.string.not_found_404)
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
@@ -96,10 +123,8 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is HttpException -> getHttpDisplayMessage(response.code, resources)
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
else -> getDisplayMessage(message, resources) ?: message
}.ifNullOrEmpty {
resources.getString(R.string.error_occurred)
}
else -> mapDisplayMessage(message, resources) ?: message
}.takeUnless { it.isNullOrBlank() }
@DrawableRes
fun Throwable.getDisplayIcon() = when (this) {
@@ -107,6 +132,8 @@ fun Throwable.getDisplayIcon() = when (this) {
is CloudFlareProtectedException -> R.drawable.ic_bot_large
is UnknownHostException,
is SocketTimeoutException,
is ConnectException,
is NoRouteToHostException,
is ProtocolException -> R.drawable.ic_plug_large
is CloudFlareBlockedException -> R.drawable.ic_denied_large
@@ -118,7 +145,9 @@ fun Throwable.getCauseUrl(): String? = when (this) {
is ParseException -> url
is NotFoundException -> url
is TooManyRequestExceptions -> url
is CaughtException -> cause?.getCauseUrl()
is CaughtException -> cause.getCauseUrl()
is WrapperIOException -> cause.getCauseUrl()
is NoDataReceivedException -> url
is CloudFlareBlockedException -> url
is CloudFlareProtectedException -> url
is HttpStatusException -> url
@@ -128,14 +157,16 @@ fun Throwable.getCauseUrl(): String? = when (this) {
private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) {
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)
else -> null
}
private fun getDisplayMessage(msg: String?, resources: Resources): String? = when {
private fun mapDisplayMessage(msg: String?, resources: Resources): String? = when {
msg.isNullOrEmpty() -> null
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 == 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_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
@@ -149,7 +180,10 @@ fun Throwable.isReportable(): Boolean {
return true
}
if (this is CaughtException) {
return cause?.isReportable() == true
return cause.isReportable()
}
if (this is WrapperIOException) {
return cause.isReportable()
}
if (ExceptionResolver.canResolve(this)) {
return false
@@ -160,6 +194,9 @@ fun Throwable.isReportable(): Boolean {
|| this is CloudFlareProtectedException
|| this is BadBackupFormatException
|| this is WrongPasswordException
|| this is TooManyRequestExceptions
|| this is HttpStatusException
|| this is SocketException
) {
return false
}
@@ -170,9 +207,13 @@ fun Throwable.isNetworkError(): Boolean {
return this is UnknownHostException || this is SocketTimeoutException
}
fun Throwable.report() {
val exception = CaughtException(this, "${javaClass.simpleName}($message)")
exception.sendWithAcra()
fun Throwable.report(silent: Boolean = false) {
val exception = CaughtException(this)
if (silent) {
exception.sendSilentlyWithAcra()
} else {
exception.sendWithAcra()
}
}
fun Throwable.isWebViewUnavailable(): Boolean {
@@ -182,3 +223,9 @@ fun Throwable.isWebViewUnavailable(): Boolean {
@Suppress("FunctionName")
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)
fun Throwable.isSerializable() = runCatching {
val oos = ObjectOutputStream(NullOutputStream())
oos.writeObject(this)
oos.flush()
}.isSuccess

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.core.util.ext
import android.net.Uri
import androidx.core.net.toUri
import okio.Path
import java.io.File
const val URI_SCHEME_ZIP = "file+zip"
@@ -20,6 +22,17 @@ fun Uri.isNetworkUri() = scheme.let {
it == URI_SCHEME_HTTP || it == URI_SCHEME_HTTPS
}
fun File.toZipUri(entryName: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryName")
fun File.toZipUri(entryPath: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryPath")
fun File.toZipUri(entryPath: Path?): Uri =
toZipUri(entryPath?.toString()?.removePrefix(Path.DIRECTORY_SEPARATOR).orEmpty())
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
fun File.toUri(fragment: String?): Uri = toUri().run {
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.WorkQuery
import androidx.work.WorkRequest
import androidx.work.await
import androidx.work.impl.WorkManagerImpl
import androidx.work.impl.model.WorkSpec
import kotlinx.coroutines.guava.await
import java.util.UUID
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -63,7 +63,7 @@ suspend fun WorkManager.awaitWorkInfoById(id: UUID): WorkInfo? {
@SuppressLint("RestrictedApi")
suspend fun WorkManager.awaitUniqueWorkInfoByName(name: String): List<WorkInfo> {
return getWorkInfosForUniqueWork(name).await().orEmpty()
return getWorkInfosForUniqueWork(name).await()
}
@SuppressLint("RestrictedApi")

View File

@@ -2,39 +2,41 @@ package org.koitharu.kotatsu.core.zip
import androidx.annotation.WorkerThread
import androidx.collection.ArraySet
import okhttp3.internal.closeQuietly
import okio.Closeable
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.withChildren
import java.io.File
import java.io.FileInputStream
import java.util.concurrent.atomic.AtomicBoolean
import java.io.FileOutputStream
import java.util.zip.Deflater
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
class ZipOutput(
val file: File,
compressionLevel: Int = Deflater.DEFAULT_COMPRESSION,
private val compressionLevel: Int = Deflater.DEFAULT_COMPRESSION,
) : Closeable {
private val entryNames = ArraySet<String>()
private val isClosed = AtomicBoolean(false)
private val output = ZipOutputStream(file.outputStream()).apply {
setLevel(compressionLevel)
// FIXME: Deflater has been closed
private var cachedOutput: ZipOutputStream? = null
private var append: Boolean = false
@Blocking
fun put(name: String, file: File): Boolean = withOutput { output ->
output.appendFile(file, name)
}
@WorkerThread
fun put(name: String, file: File): Boolean {
return output.appendFile(file, name)
@Blocking
fun put(name: String, content: String): Boolean = withOutput { output ->
output.appendText(content, name)
}
@WorkerThread
fun put(name: String, content: String): Boolean {
return output.appendText(content, name)
}
@WorkerThread
@Blocking
fun addDirectory(name: String): Boolean {
val entry = if (name.endsWith("/")) {
ZipEntry(name)
@@ -42,24 +44,8 @@ class ZipOutput(
ZipEntry("$name/")
}
return if (entryNames.add(entry.name)) {
output.putNextEntry(entry)
output.closeEntry()
true
} else {
false
}
}
@WorkerThread
fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean {
return if (entryNames.add(entry.name)) {
val zipEntry = ZipEntry(entry.name)
output.putNextEntry(zipEntry)
try {
other.getInputStream(entry).use { input ->
input.copyTo(output)
}
} finally {
withOutput { output ->
output.putNextEntry(entry)
output.closeEntry()
}
true
@@ -68,15 +54,39 @@ class ZipOutput(
}
}
fun finish() {
output.finish()
output.flush()
@Blocking
fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean {
return if (entryNames.add(entry.name)) {
val zipEntry = ZipEntry(entry.name)
withOutput { output ->
output.putNextEntry(zipEntry)
try {
other.getInputStream(entry).use { input ->
input.copyTo(output)
}
} finally {
output.closeEntry()
}
}
true
} else {
false
}
}
@Blocking
fun finish() = withOutput { output ->
output.finish()
}
@Synchronized
override fun close() {
if (isClosed.compareAndSet(false, true)) {
output.close()
try {
cachedOutput?.close()
} catch (e: NullPointerException) {
e.printStackTraceDebug()
}
cachedOutput = null
}
@WorkerThread
@@ -128,4 +138,30 @@ class ZipOutput(
}
return true
}
@Synchronized
private fun <T> withOutput(block: (ZipOutputStream) -> T): T {
contract {
callsInPlace(block, InvocationKind.AT_LEAST_ONCE)
}
return try {
(cachedOutput ?: newOutput(append)).withOutputImpl(block).also {
append = true // after 1st success write
}
} catch (e: NullPointerException) { // probably NullPointerException: Deflater has been closed
newOutput(append).withOutputImpl(block)
}
}
private fun <T> ZipOutputStream.withOutputImpl(block: (ZipOutputStream) -> T): T {
val res = block(this)
flush()
return res
}
private fun newOutput(append: Boolean) = ZipOutputStream(FileOutputStream(file, append)).also {
it.setLevel(compressionLevel)
cachedOutput?.closeQuietly()
cachedOutput = it
}
}

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.details.domain
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.model.findChapter
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -33,8 +32,8 @@ class ProgressUpdateUseCase @Inject constructor(
} else {
seed
}
val chapter = details.findChapter(history.chapterId) ?: return PROGRESS_NONE
val chapters = details.getChapters(chapter.branch) ?: return PROGRESS_NONE
val chapter = details.findChapterById(history.chapterId) ?: return PROGRESS_NONE
val chapters = details.getChapters(chapter.branch)
val chaptersCount = chapters.size
if (chaptersCount == 0) {
return PROGRESS_NONE

View File

@@ -1,10 +1,10 @@
package org.koitharu.kotatsu.details.domain
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.data.ReadingTime
import org.koitharu.kotatsu.parsers.util.findById
import org.koitharu.kotatsu.stats.data.StatsRepository
import java.util.concurrent.TimeUnit
import javax.inject.Inject

View File

@@ -6,7 +6,6 @@ import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
@@ -19,6 +18,7 @@ import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.findById
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
@@ -34,7 +34,7 @@ class MangaPrefetchService : CoroutineIntentService() {
@Inject
lateinit var historyRepository: HistoryRepository
override suspend fun processIntent(startId: Int, intent: Intent) {
override suspend fun IntentJobContext.processIntent(intent: Intent) {
when (intent.action) {
ACTION_PREFETCH_DETAILS -> prefetchDetails(
manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga
@@ -50,7 +50,7 @@ class MangaPrefetchService : CoroutineIntentService() {
}
}
override fun onError(startId: Int, error: Throwable) = Unit
override fun IntentJobContext.onError(error: Throwable) = Unit
private suspend fun prefetchDetails(manga: Manga) {
val source = mangaRepositoryFactory.create(manga.source)

View File

@@ -25,7 +25,7 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import coil3.ImageLoader
import coil3.request.ImageRequest
import coil3.request.SuccessResult
@@ -77,7 +77,6 @@ import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.drawable
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.isTextTruncated
import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
@@ -113,6 +112,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
@@ -127,7 +127,7 @@ class DetailsActivity :
View.OnClickListener,
View.OnLongClickListener, PopupMenu.OnMenuItemClickListener, View.OnLayoutChangeListener,
ViewTreeObserver.OnDrawListener, ChipsView.OnChipClickListener, OnListItemClickListener<Bookmark>,
OnContextClickListenerCompat {
OnContextClickListenerCompat, SwipeRefreshLayout.OnRefreshListener {
@Inject
lateinit var shortcutManager: AppShortcutManager
@@ -165,6 +165,7 @@ class DetailsActivity :
viewBinding.infoLayout.chipSource.setOnClickListener(this)
viewBinding.infoLayout.chipSize.setOnClickListener(this)
viewBinding.textViewDescription.addOnLayoutChangeListener(this)
viewBinding.swipeRefreshLayout.setOnRefreshListener(this)
viewBinding.textViewDescription.viewTreeObserver.addOnDrawListener(this)
viewBinding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
viewBinding.chipsTags.onChipClickListener = this
@@ -273,7 +274,7 @@ class DetailsActivity :
startActivity(
ImageActivity.newIntent(
v.context,
manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl },
manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl } ?: return,
manga.source,
),
scaleUpActivityOptionsOf(v),
@@ -349,6 +350,10 @@ class DetailsActivity :
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
override fun onRefresh() {
viewModel.reload()
}
override fun onDraw() {
viewBinding.run {
buttonDescriptionMore.isVisible = textViewDescription.maxLines == Int.MAX_VALUE ||
@@ -420,18 +425,7 @@ class DetailsActivity :
}
private fun onLoadingStateChanged(isLoading: Boolean) {
val button = viewBinding.buttonDownload ?: return
if (isLoading) {
button.setImageDrawable(
CircularProgressDrawable(this).also {
it.setStyle(CircularProgressDrawable.LARGE)
it.setColorSchemeColors(getThemeColor(materialR.attr.colorControlNormal))
it.start()
},
)
} else {
button.setImageResource(R.drawable.ic_download)
}
viewBinding.swipeRefreshLayout.isRefreshing = isLoading
}
private fun onScrobblingInfoChanged(scrobblings: List<ScrobblingInfo>) {

View File

@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isNetworkError
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.exception.ParseException
@@ -38,7 +39,7 @@ class DetailsErrorObserver(
value is ParseException -> {
val fm = fragmentManager
if (fm != null) {
if (fm != null && value.isSerializable()) {
snackbar.setAction(R.string.details) {
ErrorDetailsDialog.show(fm, value, value.url)
}

View File

@@ -20,7 +20,6 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -47,6 +46,7 @@ import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.findById
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.details.ui.adapter
import android.graphics.Typeface
import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.model.formatNumber
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
@@ -22,7 +21,7 @@ fun chapterGridItemAD(
bind { payloads ->
if (payloads.isEmpty()) {
binding.textViewTitle.text = item.chapter.formatNumber() ?: "?"
binding.textViewTitle.text = item.chapter.numberString() ?: "?"
}
binding.imageViewNew.isVisible = item.isNew
binding.imageViewCurrent.isVisible = item.isCurrent

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.details.ui.adapter
import android.content.Context
import org.koitharu.kotatsu.core.model.formatNumber
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
@@ -33,7 +32,7 @@ class ChaptersAdapter(
findHeader(position)?.getText(context)
} else {
val chapter = (items.getOrNull(position) as? ChapterListItem)?.chapter ?: return null
if (chapter.number > 0) chapter.formatNumber() else null
chapter.numberString()
}
}
}

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.details.ui.model
import android.text.format.DateUtils
import org.jsoup.internal.StringUtil.StringJoiner
import org.koitharu.kotatsu.core.model.formatNumber
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaChapter
import kotlin.experimental.and
@@ -53,7 +52,7 @@ data class ChapterListItem(
private fun buildDescription(): String {
val joiner = StringJoiner("")
chapter.formatNumber()?.let {
chapter.numberString()?.let {
joiner.add("#").append(it)
}
uploadDate?.let { date ->

View File

@@ -166,8 +166,9 @@ abstract class ChaptersPagesViewModel(
fun download(chaptersIds: Set<Long>?, allowMeteredNetwork: Boolean) {
launchJob(Dispatchers.Default) {
val manga = requireManga()
val task = DownloadTask(
mangaId = requireManga().id,
mangaId = manga.id,
isPaused = false,
isSilent = false,
chaptersIds = chaptersIds?.toLongArray(),
@@ -175,7 +176,7 @@ abstract class ChaptersPagesViewModel(
format = null,
allowMeteredNetwork = allowMeteredNetwork,
)
downloadScheduler.schedule(setOf(task))
downloadScheduler.schedule(setOf(manga to task))
onDownloadStarted.call(Unit)
}
}

View File

@@ -62,10 +62,12 @@ class ChaptersSelectionCallback(
R.id.action_save -> {
val snapshot = controller.snapshot()
mode?.finish()
commonAlertDialogs.askForDownloadOverMeteredNetwork(
context = recyclerView.context,
onConfirmed = { viewModel.download(snapshot, it) },
)
if (snapshot.isNotEmpty()) {
commonAlertDialogs.askForDownloadOverMeteredNetwork(
context = recyclerView.context,
onConfirmed = { viewModel.download(snapshot, it) },
)
}
true
}

View File

@@ -79,16 +79,14 @@ class MangaPageFetcher(
}
}
private fun Response.toNetworkResponse(): NetworkResponse {
return NetworkResponse(
code = code,
requestMillis = sentRequestAtMillis,
responseMillis = receivedResponseAtMillis,
headers = headers.toNetworkHeaders(),
body = body?.source()?.let(::NetworkResponseBody),
delegate = this,
)
}
private fun Response.toNetworkResponse() = NetworkResponse(
code = code,
requestMillis = sentRequestAtMillis,
responseMillis = receivedResponseAtMillis,
headers = headers.toNetworkHeaders(),
body = body?.source()?.let(::NetworkResponseBody),
delegate = this,
)
private fun Headers.toNetworkHeaders(): NetworkHeaders {
val headers = NetworkHeaders.Builder()

View File

@@ -17,6 +17,7 @@ import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.setTextColorAttr
import org.koitharu.kotatsu.databinding.ItemPageThumbBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import com.google.android.material.R as materialR
fun pageThumbnailAD(
@@ -36,7 +37,7 @@ fun pageThumbnailAD(
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
bind {
val data: Any = item.page.preview?.takeUnless { it.isEmpty() } ?: item.page.toMangaPage()
val data: Any = item.page.preview?.nullIfEmpty() ?: item.page.toMangaPage()
binding.imageViewThumb.newImageRequest(lifecycleOwner, data)?.run {
defaultPlaceholders(context)
size(thumbSize)

View File

@@ -2,8 +2,13 @@ package org.koitharu.kotatsu.details.ui.pager.pages
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.view.ActionMode
import androidx.collection.ArraySet
import androidx.core.graphics.Insets
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
@@ -20,10 +25,12 @@ import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.BoundsScrollListener
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.dismissParentDialog
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
@@ -34,16 +41,18 @@ import org.koitharu.kotatsu.list.ui.GridSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import javax.inject.Inject
import kotlin.math.roundToInt
@AndroidEntryPoint
class PagesFragment :
BaseFragment<FragmentPagesBinding>(),
OnListItemClickListener<PageThumbnail> {
OnListItemClickListener<PageThumbnail>, ListSelectionController.Callback {
@Inject
lateinit var coil: ImageLoader
@@ -51,17 +60,23 @@ class PagesFragment :
@Inject
lateinit var settings: AppSettings
@Inject
lateinit var pageSaveHelperFactory: PageSaveHelper.Factory
private val parentViewModel by ChaptersPagesViewModel.ActivityVMLazy(this)
private val viewModel by viewModels<PagesViewModel>()
private lateinit var pageSaveHelper: PageSaveHelper
private var thumbnailsAdapter: PageThumbnailAdapter? = null
private var spanResolver: GridSpanResolver? = null
private var scrollListener: ScrollListener? = null
private var selectionController: ListSelectionController? = null
private val spanSizeLookup = SpanSizeLookup()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pageSaveHelper = pageSaveHelperFactory.create(this)
combine(
parentViewModel.mangaDetails,
parentViewModel.readingState,
@@ -83,6 +98,12 @@ class PagesFragment :
override fun onViewBindingCreated(binding: FragmentPagesBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
spanResolver = GridSpanResolver(binding.root.resources)
selectionController = ListSelectionController(
appCompatDelegate = checkNotNull(findAppCompatDelegate()),
decoration = PagesSelectionDecoration(binding.root.context),
registryOwner = this,
callback = this,
)
thumbnailsAdapter = PageThumbnailAdapter(
coil = coil,
lifecycleOwner = viewLifecycleOwner,
@@ -91,6 +112,7 @@ class PagesFragment :
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) // before rv initialization
with(binding.recyclerView) {
addItemDecoration(TypedListSpacingDecoration(context, false))
checkNotNull(selectionController).attachToRecyclerView(this)
adapter = thumbnailsAdapter
setHasFixedSize(true)
PagerNestedScrollHelper(this).bind(viewLifecycleOwner)
@@ -103,6 +125,7 @@ class PagesFragment :
}
parentViewModel.isChaptersEmpty.observe(viewLifecycleOwner, ::onNoChaptersChanged)
viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged)
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(binding.recyclerView))
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) }
viewModel.isLoadingUp.observe(viewLifecycleOwner) { binding.progressBarTop.showOrHide(it) }
@@ -113,6 +136,7 @@ class PagesFragment :
spanResolver = null
scrollListener = null
thumbnailsAdapter = null
selectionController = null
spanSizeLookup.invalidateCache()
super.onDestroyView()
}
@@ -120,6 +144,9 @@ class PagesFragment :
override fun onWindowInsetsChanged(insets: Insets) = Unit
override fun onItemClick(item: PageThumbnail, view: View) {
if (selectionController?.onItemClick(item.page.id) == true) {
return
}
val listener = findParentCallback(ReaderNavigationCallback::class.java)
if (listener != null && listener.onPageSelected(item.page)) {
dismissParentDialog()
@@ -133,6 +160,39 @@ class PagesFragment :
}
}
override fun onItemLongClick(item: PageThumbnail, view: View): Boolean {
return selectionController?.onItemLongClick(view, item.page.id) ?: false
}
override fun onItemContextClick(item: PageThumbnail, view: View): Boolean {
return selectionController?.onItemContextClick(view, item.page.id) ?: false
}
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
viewBinding?.recyclerView?.invalidateItemDecorations()
}
override fun onCreateActionMode(
controller: ListSelectionController,
menuInflater: MenuInflater,
menu: Menu,
): Boolean {
menuInflater.inflate(R.menu.mode_pages, menu)
return true
}
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_save -> {
viewModel.savePages(pageSaveHelper, collectSelectedPages())
mode?.finish()
true
}
else -> false
}
}
private suspend fun onThumbnailsChanged(list: List<ListModel>) {
val adapter = thumbnailsAdapter ?: return
if (adapter.itemCount == 0) {
@@ -172,6 +232,18 @@ class PagesFragment :
}
}
private fun collectSelectedPages(): Set<ReaderPage> {
val checkedIds = selectionController?.peekCheckedIds() ?: return emptySet()
val items = thumbnailsAdapter?.items ?: return emptySet()
val result = ArraySet<ReaderPage>(checkedIds.size)
for (item in items) {
if (item is PageThumbnail && item.page.id in checkedIds) {
result.add(item.page)
}
}
return result
}
private inner class ScrollListener : BoundsScrollListener(3, 3) {
override fun onScrolledToStart(recyclerView: RecyclerView) {

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.details.ui.pager.pages
import android.net.Uri
import android.view.View
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ShareHelper
class PagesSavedObserver(
private val snackbarHost: View,
) : FlowCollector<Collection<Uri>> {
override suspend fun emit(value: Collection<Uri>) {
val msg = when (value.size) {
0 -> R.string.nothing_found
1 -> R.string.page_saved
else -> R.string.pages_saved
}
val snackbar = Snackbar.make(snackbarHost, msg, Snackbar.LENGTH_LONG)
value.singleOrNull()?.let { uri ->
snackbar.setAction(R.string.share) {
ShareHelper(snackbarHost.context).shareImage(uri)
}
}
snackbar.show()
}
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.details.ui.pager.pages
import android.content.Context
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.core.util.ext.getItem
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
class PagesSelectionDecoration(context: Context) : MangaSelectionDecoration(context) {
override fun getItemId(parent: RecyclerView, child: View): Long {
val holder = parent.getChildViewHolder(child) ?: return RecyclerView.NO_ID
val item = holder.getItem(PageThumbnail::class.java) ?: return RecyclerView.NO_ID
return item.page.id
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.details.ui.pager.pages
import android.net.Uri
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
@@ -10,12 +11,17 @@ import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.firstNotNull
import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.domain.ChaptersLoader
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import javax.inject.Inject
@HiltViewModel
@@ -32,6 +38,7 @@ class PagesViewModel @Inject constructor(
val thumbnails = MutableStateFlow<List<ListModel>>(emptyList())
val isLoadingUp = MutableStateFlow(false)
val isLoadingDown = MutableStateFlow(false)
val onPageSaved = MutableEventFlow<Collection<Uri>>()
val gridScale = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
@@ -73,6 +80,25 @@ class PagesViewModel @Inject constructor(
loadingNextJob = loadPrevNextChapter(isNext = true)
}
fun savePages(
pageSaveHelper: PageSaveHelper,
pages: Set<ReaderPage>,
) {
launchLoadingJob(Dispatchers.Default) {
val manga = state.requireValue().details.toManga()
val tasks = pages.map {
PageSaveHelper.Task(
manga = manga,
chapter = manga.requireChapterById(it.chapterId),
pageNumber = it.index + 1,
page = it.toMangaPage(),
)
}
val dest = pageSaveHelper.save(tasks)
onPageSaved.call(dest)
}
}
private suspend fun doInit(state: State) {
chaptersLoader.init(state.details)
val initialChapterId = state.readerState?.chapterId?.takeIf {

View File

@@ -44,7 +44,7 @@ interface ChaptersSelectMacro {
) : ChaptersSelectMacro {
override fun getChaptersIds(mangaId: Long, chapters: List<MangaChapter>): Set<Long> {
val result = ArraySet<Long>(chaptersCount)
val result = ArraySet<Long>(minOf(chaptersCount, chapters.size))
for (c in chapters) {
if (c.branch == branch) {
result.add(c.id)
@@ -72,7 +72,7 @@ interface ChaptersSelectMacro {
val currentChapterId = currentChaptersIds.getOrDefault(mangaId, chapters.first().id)
var branch: String? = null
var isAdding = false
val result = ArraySet<Long>(chaptersCount)
val result = ArraySet<Long>(minOf(chaptersCount, chapters.size))
for (c in chapters) {
if (!isAdding) {
if (c.id == currentChapterId) {

View File

@@ -27,7 +27,6 @@ import org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView
import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit
import org.koitharu.kotatsu.core.util.ext.mapToArray
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.parentView
@@ -39,6 +38,7 @@ import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.parsers.util.mapToArray
import org.koitharu.kotatsu.settings.storage.DirectoryModel
import javax.inject.Inject

View File

@@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.DownloadFormat
@@ -22,23 +21,22 @@ import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.sizeOrZero
import org.koitharu.kotatsu.download.ui.worker.DownloadTask
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.sizeOrZero
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import org.koitharu.kotatsu.settings.storage.DirectoryModel
import javax.inject.Inject
@HiltViewModel
class DownloadDialogViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val mangaDataRepository: MangaDataRepository,
private val scheduler: DownloadWorker.Scheduler,
private val localStorageManager: LocalStorageManager,
private val localMangaRepository: LocalMangaRepository,
@@ -50,7 +48,7 @@ class DownloadDialogViewModel @Inject constructor(
val manga = savedStateHandle.require<Array<ParcelableManga>>(DownloadDialogFragment.ARG_MANGA).map {
it.manga
}
private val mangaDetails = SuspendLazy {
private val mangaDetails = suspendLazy {
coroutineScope {
manga.map { m ->
async { m.getDetails() }
@@ -94,8 +92,7 @@ class DownloadDialogViewModel @Inject constructor(
launchLoadingJob(Dispatchers.Default) {
val tasks = mangaDetails.get().map { m ->
val chapters = checkNotNull(m.chapters) { "Manga \"${m.title}\" cannot be loaded" }
mangaDataRepository.storeManga(m)
DownloadTask(
m to DownloadTask(
mangaId = m.id,
isPaused = !startNow,
isSilent = false,

View File

@@ -22,10 +22,8 @@ import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.formatNumber
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
@@ -309,7 +307,7 @@ class DownloadsViewModel @Inject constructor(
return chapters.mapNotNullTo(ArrayList(size)) {
if (chapterIds == null || it.id in chapterIds) {
DownloadChapter(
number = it.formatNumber(),
number = it.numberString(),
name = it.name,
isDownloaded = it.id in localChapters,
)
@@ -327,6 +325,6 @@ class DownloadsViewModel @Inject constructor(
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
private suspend fun tryLoad(manga: Manga) = runCatchingCancellable {
(mangaRepositoryFactory.create(manga.source) as ParserMangaRepository).getDetails(manga)
mangaRepositoryFactory.create(manga.source).getDetails(manga)
}.getOrNull()
}

View File

@@ -1,16 +1,20 @@
package org.koitharu.kotatsu.download.ui.worker
import android.os.SystemClock
import androidx.collection.MutableObjectLongMap
import kotlinx.coroutines.delay
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaSource
import javax.inject.Inject
import javax.inject.Singleton
class DownloadSlowdownDispatcher(
@Singleton
class DownloadSlowdownDispatcher @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val defaultDelay: Long,
) {
private val timeMap = MutableObjectLongMap<MangaSource>()
private val defaultDelay = 1_600L
suspend fun delay(source: MangaSource) {
val repo = mangaRepositoryFactory.create(source) as? ParserMangaRepository ?: return
@@ -19,11 +23,11 @@ class DownloadSlowdownDispatcher(
}
val lastRequest = synchronized(timeMap) {
val res = timeMap.getOrDefault(source, 0L)
timeMap[source] = System.currentTimeMillis()
timeMap[source] = SystemClock.elapsedRealtime()
res
}
if (lastRequest != 0L) {
delay(lastRequest + defaultDelay - System.currentTimeMillis())
delay(lastRequest + defaultDelay - SystemClock.elapsedRealtime())
}
}
}

View File

@@ -60,7 +60,6 @@ import org.koitharu.kotatsu.core.util.ext.ensureSuccess
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getWorkInputData
import org.koitharu.kotatsu.core.util.ext.getWorkSpec
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.withTicker
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
@@ -71,7 +70,7 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.TempFileFilter
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.input.LocalMangaParser
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.domain.MangaLock
import org.koitharu.kotatsu.local.domain.model.LocalManga
@@ -79,6 +78,7 @@ import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.requireBody
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -101,6 +101,7 @@ class DownloadWorker @AssistedInject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val settings: AppSettings,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
private val slowdownDispatcher: DownloadSlowdownDispatcher,
private val imageProxyInterceptor: ImageProxyInterceptor,
notificationFactoryFactory: DownloadNotificationFactory.Factory,
) : CoroutineWorker(appContext, params) {
@@ -108,7 +109,6 @@ class DownloadWorker @AssistedInject constructor(
private val task = DownloadTask(params.inputData)
private val notificationFactory = notificationFactoryFactory.create(uuid = params.id, isSilent = task.isSilent)
private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val slowdownDispatcher = DownloadSlowdownDispatcher(mangaRepositoryFactory, SLOWDOWN_DELAY)
@Volatile
private var lastPublishedState: DownloadState? = null
@@ -199,7 +199,7 @@ class DownloadWorker @AssistedInject constructor(
format = task.format ?: settings.preferredDownloadFormat,
)
val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl }
if (coverUrl.isNotEmpty()) {
if (!coverUrl.isNullOrEmpty()) {
downloadFile(coverUrl, destination, repo.source).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
file.deleteAwait()
@@ -262,7 +262,7 @@ class DownloadWorker @AssistedInject constructor(
}
if (output.flushChapter(chapter.value)) {
runCatchingCancellable {
localStorageChanges.emit(LocalMangaInput.of(output.rootFile).getManga())
localStorageChanges.emit(LocalMangaParser(output.rootFile).getManga(withDetails = false))
}.onFailure(Throwable::printStackTraceDebug)
}
publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1))
@@ -270,7 +270,7 @@ class DownloadWorker @AssistedInject constructor(
publishState(currentState.copy(isIndeterminate = true, eta = -1L, isStuck = false))
output.mergeWithExisting()
output.finish()
val localManga = LocalMangaInput.of(output.rootFile).getManga()
val localManga = LocalMangaParser(output.rootFile).getManga(withDetails = false)
localStorageChanges.emit(localManga)
publishState(currentState.copy(localManga = localManga, eta = -1L, isStuck = false))
} catch (e: Exception) {
@@ -433,6 +433,7 @@ class DownloadWorker @AssistedInject constructor(
@Reusable
class Scheduler @Inject constructor(
@ApplicationContext private val context: Context,
private val mangaDataRepository: MangaDataRepository,
private val workManager: WorkManager,
) {
@@ -507,11 +508,12 @@ class DownloadWorker @AssistedInject constructor(
}
}
suspend fun schedule(tasks: Collection<DownloadTask>) {
suspend fun schedule(tasks: Collection<Pair<Manga, DownloadTask>>) {
if (tasks.isEmpty()) {
return
}
val requests = tasks.map { task ->
val requests = tasks.map { (manga, task) ->
mangaDataRepository.storeManga(manga)
OneTimeWorkRequestBuilder<DownloadWorker>()
.setConstraints(createConstraints(task.allowMeteredNetwork))
.addTag(TAG)
@@ -535,7 +537,6 @@ class DownloadWorker @AssistedInject constructor(
const val MAX_PAGES_PARALLELISM = 4
const val DOWNLOAD_ERROR_DELAY = 2_000L
const val MAX_RETRY_DELAY = 7_200_000L // 2 hours
const val SLOWDOWN_DELAY = 200L
const val TAG = "download"
}
}

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.explore.domain
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.almostEquals
import org.koitharu.kotatsu.core.util.ext.asArrayList
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
@@ -11,6 +10,7 @@ import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.almostEquals
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist
import javax.inject.Inject
@@ -77,7 +77,7 @@ class ExploreRepository @Inject constructor(
val list = repository.getList(
offset = 0,
order = order,
filter = MangaListFilter(tags = setOfNotNull(tag))
filter = MangaListFilter(tags = setOfNotNull(tag)),
).asArrayList()
if (settings.isSuggestionsExcludeNsfw) {
list.removeAll { it.isNsfw }

View File

@@ -1,8 +1,8 @@
package org.koitharu.kotatsu.explore.ui.model
import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.util.longHashCode
data class MangaSourceItem(
val source: MangaSourceInfo,

View File

@@ -3,7 +3,7 @@ package org.koitharu.kotatsu.favourites.domain.model
import org.koitharu.kotatsu.core.model.MangaSource
data class Cover(
val url: String,
val url: String?,
val source: String,
) {
val mangaSource by lazy { MangaSource(source) }

View File

@@ -65,7 +65,7 @@ class FavoriteSheet : BaseAdaptiveSheet<SheetFavoriteCategoriesBinding>(), OnLis
fun show(fm: FragmentManager, manga: Collection<Manga>) = FavoriteSheet().withArgs(1) {
putParcelableArrayList(
KEY_MANGA_LIST,
manga.mapTo(ArrayList(manga.size), ::ParcelableManga),
manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withDescription = false) },
)
}.showDistinct(fm, TAG)
}

View File

@@ -52,7 +52,8 @@ class FavoriteSheetViewModel @Inject constructor(
settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled },
) { categories, _, tracker ->
mapList(categories, tracker)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(header))
}.withErrorHandling()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(header))
fun setChecked(categoryId: Long, isChecked: Boolean) {
launchJob(Dispatchers.Default) {

View File

@@ -35,8 +35,9 @@ import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.model.YEAR_MIN
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.ifZero
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import java.util.Calendar
@@ -59,7 +60,7 @@ class FilterCoordinator @Inject constructor(
private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder)
private val availableSortOrders = repository.sortOrders
private val filterOptions = SuspendLazy { repository.getFilterOptions() }
private val filterOptions = suspendLazy { repository.getFilterOptions() }
val capabilities = repository.filterCapabilities
val mangaSource: MangaSource
@@ -267,7 +268,7 @@ class FilterCoordinator @Inject constructor(
}
fun setQuery(value: String?) {
val newQuery = value?.trim()?.takeUnless { it.isEmpty() }
val newQuery = value?.trim()?.nullIfEmpty()
currentListFilter.update { oldValue ->
if (capabilities.isSearchWithFiltersSupported || newQuery == null) {
oldValue.copy(query = newQuery)

View File

@@ -9,6 +9,7 @@ import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.core.view.isGone
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import com.google.android.material.chip.Chip
import com.google.android.material.slider.RangeSlider
@@ -356,5 +357,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
private const val TAG = "FilterSheet"
fun show(fm: FragmentManager) = FilterSheetFragment().showDistinct(fm, TAG)
fun isSupported(fragment: Fragment) = fragment.activity is FilterCoordinator.Owner
}
}

View File

@@ -122,6 +122,8 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback {
suspend fun deleteAfter(minDate: Long) = setDeletedAtAfter(minDate, System.currentTimeMillis())
suspend fun deleteNotFavorite() = setDeletedAtNotFavorite(System.currentTimeMillis())
suspend fun clear() = setDeletedAtAfter(0L, System.currentTimeMillis())
suspend fun update(entity: HistoryEntity) = update(
@@ -157,6 +159,9 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback {
@Query("UPDATE history SET deleted_at = :deletedAt WHERE created_at >= :minDate AND deleted_at = 0")
protected abstract suspend fun setDeletedAtAfter(minDate: Long, deletedAt: Long)
@Query("UPDATE history SET deleted_at = :deletedAt WHERE deleted_at = 0 AND NOT EXISTS(SELECT * FROM favourites WHERE history.manga_id = favourites.manga_id)")
protected abstract suspend fun setDeletedAtNotFavorite(deletedAt: Long)
@Transaction
@RawQuery(observedEntities = [HistoryEntity::class])
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<HistoryWithManga>>

View File

@@ -14,7 +14,6 @@ import org.koitharu.kotatsu.core.db.entity.toMangaList
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.db.entity.toMangaTagsList
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.model.toMangaSources
@@ -30,6 +29,7 @@ import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.findById
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.tryScrobble
@@ -164,6 +164,10 @@ class HistoryRepository @Inject constructor(
db.getHistoryDao().deleteAfter(minDate)
}
suspend fun deleteNotFavorite() {
db.getHistoryDao().deleteNotFavorite()
}
suspend fun delete(ids: Collection<Long>): ReversibleHandle {
db.withTransaction {
for (id in ids) {

View File

@@ -53,6 +53,7 @@ class HistoryListMenuProvider(
arrayOf(
context.getString(R.string.last_2_hours),
context.getString(R.string.today),
context.getString(R.string.not_in_favorites),
context.getString(R.string.clear_all_history),
),
selectionListener.selection,
@@ -61,13 +62,12 @@ class HistoryListMenuProvider(
setIcon(R.drawable.ic_delete_all)
setNegativeButton(android.R.string.cancel, null)
setPositiveButton(R.string.clear) { _, _ ->
val minDate = when (selectionListener.selection) {
0 -> Instant.now().minus(2, ChronoUnit.HOURS)
1 -> LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant()
2 -> Instant.EPOCH
else -> return@setPositiveButton
when (selectionListener.selection) {
0 -> viewModel.clearHistory(Instant.now().minus(2, ChronoUnit.HOURS))
1 -> viewModel.clearHistory(LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant())
2 -> viewModel.removeNotFavorite()
3 -> viewModel.clearHistory(null)
}
viewModel.clearHistory(minDate)
}
}.show()
}

View File

@@ -101,9 +101,9 @@ class HistoryListViewModel @Inject constructor(
override fun onRetry() = Unit
fun clearHistory(minDate: Instant) {
fun clearHistory(minDate: Instant?) {
launchJob(Dispatchers.Default) {
val stringRes = if (minDate <= Instant.EPOCH) {
val stringRes = if (minDate == null) {
repository.clear()
R.string.history_cleared
} else {
@@ -114,6 +114,13 @@ class HistoryListViewModel @Inject constructor(
}
}
fun removeNotFavorite() {
launchJob(Dispatchers.Default) {
repository.deleteNotFavorite()
onActionDone.call(ReversibleAction(R.string.removed_from_history, null))
}
}
fun removeFromHistory(ids: Set<Long>) {
if (ids.isEmpty()) {
return

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