Compare commits

...

100 Commits
v9.0 ... v9.1.3

Author SHA1 Message Date
Koitharu
bc68441585 Update parsers 2025-08-24 11:06:16 +03:00
Koitharu
1cc51b6a88 Refactor usage WebView for parsers 2025-08-24 10:39:23 +03:00
Kusou
fd5aca7252 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (868 of 868 strings)

Co-authored-by: Kusou <orion26br@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2025-08-24 10:14:59 +03:00
Abay Emes
e447245fac Translated using Weblate (Kazakh)
Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Kazakh)

Currently translated at 62.5% (543 of 868 strings)

Co-authored-by: Abay Emes <abayemes@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/kk/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/kk/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2025-08-24 10:14:59 +03:00
Draken
5af0ee1c69 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (868 of 868 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-08-24 10:14:59 +03:00
Shayan
c02d1641ab Translated using Weblate (Persian)
Currently translated at 59.2% (514 of 868 strings)

Co-authored-by: Shayan <shayans31516@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
2025-08-24 10:14:59 +03:00
Aray LXa
f55c525c8a Translated using Weblate (Persian)
Currently translated at 59.2% (514 of 868 strings)

Co-authored-by: Aray LXa <araylxa@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
2025-08-24 10:14:59 +03:00
Максим Горпиніч
a42fc87a9a Translated using Weblate (Ukrainian)
Currently translated at 100.0% (868 of 868 strings)

Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2025-08-24 10:14:59 +03:00
Juan Rubin
6b6905fd71 Translated using Weblate (Portuguese)
Currently translated at 100.0% (868 of 868 strings)

Co-authored-by: Juan Rubin <juancrubin08@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2025-08-24 10:14:59 +03:00
Koitharu
b7f57856db Author search support for external manga sources 2025-08-24 10:13:12 +03:00
Koitharu
1d6d626b62 Update parsers 2025-08-24 09:47:29 +03:00
Koitharu
d93ff92cc9 Update parsers and fix some deprecations 2025-08-16 08:11:44 +03:00
Koitharu
8eda113f3b Captcha group notification intent 2025-08-14 15:57:18 +03:00
Koitharu
3916c2619e Update parsers 2025-08-14 11:51:33 +03:00
Milo Ivir
1d3e8e55ca Translated using Weblate (Croatian)
Currently translated at 94.0% (816 of 868 strings)

Translated using Weblate (Croatian)

Currently translated at 92.7% (805 of 868 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
2025-08-14 11:24:16 +03:00
Manuela Silva
2c3b4f29eb Translated using Weblate (Portuguese)
Currently translated at 97.8% (849 of 868 strings)

Co-authored-by: Manuela Silva <mmsrs@sky.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2025-08-14 11:24:16 +03:00
Marco Ramazzotti
ee530002b6 Translated using Weblate (Japanese)
Currently translated at 55.1% (479 of 868 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (868 of 868 strings)

Co-authored-by: Marco Ramazzotti <cuordilava@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2025-08-14 11:24:16 +03:00
Lorenzo Stella
59d530e0dc Translated using Weblate (Italian)
Currently translated at 100.0% (868 of 868 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (868 of 868 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (868 of 868 strings)

Co-authored-by: Lorenzo Stella <lorenzo.stella.1408@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-08-14 11:24:16 +03:00
Nicola Bortoletto
52a132caed Translated using Weblate (Italian)
Currently translated at 100.0% (868 of 868 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (868 of 868 strings)

Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-08-14 11:24:16 +03:00
Макар Разин
379d2dd8d4 Translated using Weblate (Russian)
Currently translated at 100.0% (868 of 868 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (868 of 868 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
2025-08-14 11:24:16 +03:00
R2E
f8cefa3e8d Translated using Weblate (Arabic)
Currently translated at 92.9% (807 of 868 strings)

Co-authored-by: R2E <mokhalad875@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2025-08-14 11:24:16 +03:00
Champ0999
5e1eda850c Translated using Weblate (Italian)
Currently translated at 99.8% (867 of 868 strings)

Co-authored-by: Champ0999 <champ0999@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-08-14 11:24:16 +03:00
gekka
18cc0ad0fb Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.5% (864 of 868 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-08-14 11:24:16 +03:00
Kuraki
11dd49c626 Translated using Weblate (Turkish)
Currently translated at 100.0% (868 of 868 strings)

Co-authored-by: Kuraki <qkuraki@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2025-08-14 11:24:16 +03:00
Draken
2ad8ab0258 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (868 of 868 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-08-14 11:24:16 +03:00
Dragibus Noir
4f8c5325a4 Translated using Weblate (French)
Currently translated at 100.0% (868 of 868 strings)

Co-authored-by: Dragibus Noir <big.confetti700@aleeas.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2025-08-14 11:24:16 +03:00
Koitharu
6e181a59a3 Update sync auth activity ui 2025-08-14 10:39:06 +03:00
Koitharu
7a7d20dbf4 AutoFixService fixes 2025-08-10 16:09:26 +03:00
Koitharu
83d5f8e378 UI fixes 2025-08-05 15:50:39 +03:00
Koitharu
5ac9bad728 Fix MultiMutex unlock when cancelled 2025-08-05 14:02:46 +03:00
Дмитро Крук
a090965a2d Translated using Weblate (Ukrainian)
Currently translated at 99.1% (860 of 867 strings)

Co-authored-by: Дмитро Крук <dimka89050@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
yunyi
1e376754bc Added translation using Weblate (Baoulé)
Co-authored-by: yunyi <1967158164@qq.com>
2025-08-04 16:12:21 +03:00
Hidayat
2cdbe52056 Translated using Weblate (Indonesian)
Currently translated at 98.7% (856 of 867 strings)

Co-authored-by: Hidayat <elbert.herry11@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
fadom06
1e09ac3ecb Translated using Weblate (German)
Currently translated at 74.9% (650 of 867 strings)

Translated using Weblate (German)

Currently translated at 74.9% (650 of 867 strings)

Co-authored-by: fadom06 <fadom06@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Draken
acc76c931a Translated using Weblate (Vietnamese)
Currently translated at 100.0% (867 of 867 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (864 of 864 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Infy's Tagalog Translations
59c12d35c1 Translated using Weblate (Filipino)
Currently translated at 99.3% (861 of 867 strings)

Translated using Weblate (Filipino)

Currently translated at 99.1% (857 of 864 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
2025-08-04 16:12:21 +03:00
周笑然
0e3cad1af1 Added translation using Weblate (Cantonese (Traditional Han script))
Added translation using Weblate (Cantonese (Traditional Han script))

Co-authored-by: 周笑然 <3140609186@qq.com>
2025-08-04 16:12:21 +03:00
Reptalica
ba8766b32d Translated using Weblate (Vietnamese)
Currently translated at 100.0% (863 of 863 strings)

Co-authored-by: Reptalica <reptalica20@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Dragibus Noir
35421cb71e Translated using Weblate (French)
Currently translated at 100.0% (867 of 867 strings)

Translated using Weblate (French)

Currently translated at 100.0% (863 of 863 strings)

Co-authored-by: Dragibus Noir <big.confetti700@aleeas.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
aicha roun souleiman
8cecd9a0e2 Translated using Weblate (French)
Currently translated at 100.0% (863 of 863 strings)

Co-authored-by: aicha roun souleiman <louqman078@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Nicola Bortoletto
523057f3e1 Translated using Weblate (Italian)
Currently translated at 99.8% (866 of 867 strings)

Translated using Weblate (Italian)

Currently translated at 99.7% (861 of 863 strings)

Translated using Weblate (Italian)

Currently translated at 98.3% (849 of 863 strings)

Translated using Weblate (Italian)

Currently translated at 97.9% (844 of 862 strings)

Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Frosted
337d196bc3 Translated using Weblate (Turkish)
Currently translated at 100.0% (867 of 867 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (867 of 867 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (863 of 863 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (862 of 862 strings)

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Hosted Weblate
c3b4c032bb 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
2025-08-04 16:12:21 +03:00
zmni
4590c753ed Translated using Weblate (Indonesian)
Currently translated at 99.8% (848 of 849 strings)

Co-authored-by: zmni <zmni@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Dragibus Noir
9733101f0c Translated using Weblate (French)
Currently translated at 100.0% (862 of 862 strings)

Translated using Weblate (French)

Currently translated at 100.0% (849 of 849 strings)

Co-authored-by: Dragibus Noir <big.confetti700@aleeas.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Robert Broketa
8cd71cc98d Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (849 of 849 strings)

Co-authored-by: Robert Broketa <robert@broketa.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Kanta Sekiguchi
42748d9c98 Translated using Weblate (Japanese)
Currently translated at 54.6% (464 of 849 strings)

Co-authored-by: Kanta Sekiguchi <kanta.sekiguchi360@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Bai
8043574314 Translated using Weblate (Turkish)
Currently translated at 100.0% (849 of 849 strings)

Co-authored-by: Bai <bai@baturax.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Shayan
44d1fdb9d3 Translated using Weblate (Persian)
Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Persian)

Currently translated at 32.9% (280 of 849 strings)

Co-authored-by: Shayan <shayans31516@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fa/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2025-08-04 16:12:21 +03:00
gekka
bc7054de4a Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.5% (863 of 867 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.5% (860 of 864 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.5% (859 of 863 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.5% (858 of 862 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.4% (843 of 848 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.4% (843 of 848 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Halbast Abdullah
4971e8ab0f Translated using Weblate (Kurdish (Central))
Currently translated at 2.5% (22 of 848 strings)

Translated using Weblate (Kurdish (Central))

Currently translated at 66.6% (6 of 9 strings)

Added translation using Weblate (Kurdish (Central))

Added translation using Weblate (Kurdish (Central))

Co-authored-by: Halbast Abdullah <halbastabdullah7@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ckb/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ckb/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2025-08-04 16:12:21 +03:00
Anon
df038b1edb Translated using Weblate (Serbian)
Currently translated at 99.5% (841 of 845 strings)

Translated using Weblate (Serbian)

Currently translated at 99.5% (841 of 845 strings)

Translated using Weblate (Serbian)

Currently translated at 99.5% (841 of 845 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Juan Rubin
7e7aabc1d1 Translated using Weblate (Portuguese)
Currently translated at 100.0% (845 of 845 strings)

Co-authored-by: Juan Rubin <juancrubin08@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Akhil Raj
9605ff89fb Translated using Weblate (Malayalam)
Currently translated at 4.7% (40 of 845 strings)

Translated using Weblate (Malayalam)

Currently translated at 4.7% (40 of 845 strings)

Translated using Weblate (Malayalam)

Currently translated at 3.5% (30 of 845 strings)

Translated using Weblate (Malayalam)

Currently translated at 3.5% (30 of 845 strings)

Co-authored-by: Akhil Raj <akhilakae07@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ml/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Draken
4ed177d29f Translated using Weblate (Vietnamese)
Currently translated at 100.0% (862 of 862 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (849 of 849 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (848 of 848 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (848 of 848 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (848 of 848 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (845 of 845 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Frosted
61cefefd10 Translated using Weblate (Turkish)
Currently translated at 100.0% (845 of 845 strings)

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Koitharu
9f965c5269 Remove Telegram bot token from public source 2025-08-04 16:11:28 +03:00
Koitharu
0c713cb799 Fix inifite captcha notifications 2025-08-04 12:50:09 +03:00
Koitharu
6d3f8cbb3b Filter for Local storage tab on main screen 2025-08-04 09:57:07 +03:00
Koitharu
05739bb5b3 Proper handling network unavailable error for images 2025-07-30 16:22:21 +03:00
Koitharu
47f0bbee17 Disk cache for favicons 2025-07-30 15:52:09 +03:00
Koitharu
dd77926dcb Improve sources settings 2025-07-30 14:50:45 +03:00
Koitharu
1b76f21507 Show reason why manga has no chapters 2025-07-30 14:06:45 +03:00
Koitharu
fe21af5443 Update parsers 2025-07-29 16:22:04 +03:00
Koitharu
0b0373021e Fix loading local manga 2025-07-26 19:28:32 +03:00
Koitharu
d641e7933d Option to show only downloaded chapters 2025-07-25 13:43:01 +03:00
Koitharu
d8efe374a8 Experimental: improve manga loading in reader 2025-07-24 15:20:42 +03:00
Koitharu
506a8b6e90 UI fixes 2025-07-23 12:08:27 +03:00
Koitharu
d81173bf76 Merge branch 'feature/discord_rpc' into devel 2025-07-22 16:42:35 +03:00
Koitharu
896452a096 Discord RPC improvements 2025-07-22 13:05:27 +03:00
Daniil Zhuravlev
35aa4d5e8f ci: add a site update trigger when the application is released 2025-07-21 08:59:34 +03:00
Koitharu
4d4c9c7a48 Discord RPC 2025-07-20 15:18:11 +03:00
Koitharu
b667e32598 Update parsers 2025-07-20 08:13:28 +03:00
Koitharu
c987fc234b Add LeakCanary to nighly builds 2025-07-17 20:42:12 +03:00
Koitharu
8142a6811b Add option to hide fab (close #1466) 2025-07-16 20:05:00 +03:00
Koitharu
3e36e1e11c Debug menu for debug builds 2025-07-16 20:05:00 +03:00
Koitharu
30aaca6341 Merge pull request #1497 from dragonx943/patch-1 2025-07-13 18:48:46 +03:00
Draken
43b34a7bca Fix gradle checksum 2025-07-13 21:04:31 +07:00
Koitharu
b23008d0ae Update Miku theme #1490 2025-07-13 11:23:15 +03:00
Koitharu
5a368b27bb Fix override applying 2025-07-13 11:07:00 +03:00
Koitharu
fe3f95d160 Cache custom covers (close #1492) 2025-07-13 10:04:18 +03:00
Koitharu
de1a297338 Fix downloading edited manga (close #1493) 2025-07-13 09:41:04 +03:00
Koitharu
d6350afe3a Upgrade target sdk 2025-07-13 09:35:24 +03:00
Koitharu
ec048c70f1 Update parsers 2025-07-10 21:39:46 +03:00
Dragibus Noir
282c1b51f7 Translated using Weblate (French)
Currently translated at 100.0% (843 of 843 strings)

Co-authored-by: Dragibus Noir <big.confetti700@aleeas.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2025-07-10 21:33:32 +03:00
Infy's Tagalog Translations
d6b6ce1bcd Translated using Weblate (Filipino)
Currently translated at 99.4% (838 of 843 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
2025-07-10 21:33:32 +03:00
Giovanni S.C
f48444dcf6 Translated using Weblate (Spanish)
Currently translated at 92.6% (781 of 843 strings)

Translated using Weblate (Spanish)

Currently translated at 92.6% (781 of 843 strings)

Co-authored-by: Giovanni S.C <giovanniandre2003@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2025-07-10 21:33:32 +03:00
Krays The Poet
15ba766643 Translated using Weblate (German)
Currently translated at 76.5% (645 of 843 strings)

Translated using Weblate (German)

Currently translated at 76.5% (645 of 843 strings)

Co-authored-by: Krays The Poet <kraysthepoet@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2025-07-10 21:33:32 +03:00
Draken
a0dbbcb350 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (845 of 845 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (843 of 843 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (843 of 843 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-07-10 21:33:32 +03:00
Frosted
f72bba9557 Translated using Weblate (Turkish)
Currently translated at 100.0% (843 of 843 strings)

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2025-07-10 21:33:32 +03:00
Макар Разин
207791aa3e Translated using Weblate (Ukrainian)
Currently translated at 99.6% (840 of 843 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.6% (840 of 843 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (843 of 843 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (843 of 843 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2025-07-10 21:33:32 +03:00
Koitharu
6319997716 Periodical backup improvements 2025-07-10 09:54:45 +03:00
Koitharu
b70c1da54b Implement missing getForegroundInfo for LocalStorageCleanupWorker 2025-07-10 08:57:11 +03:00
Koitharu
621cb19c5b Fix saving override for non-library manga 2025-07-10 08:48:10 +03:00
Koitharu
b528b7b3c1 Fix passing headers to favicon requests 2025-07-09 22:07:37 +03:00
Koitharu
9a1bb6f6fc Fix long tap on old Android versions (close #1478) 2025-07-09 21:57:37 +03:00
Koitharu
37f9c4b9f6 Fix window insets and search closing 2025-07-09 21:42:28 +03:00
Koitharu
d0084e50e7 Fix loading local manga (closes #1481, #1474, #1479, #1484, #1439) 2025-07-09 21:21:02 +03:00
Koitharu
088576cc9d Ignore network error for background progress update 2025-07-07 13:46:37 +03:00
Koitharu
f0ba42b518 Fix captcha notification dismissing 2025-07-06 12:09:30 +03:00
196 changed files with 3730 additions and 1282 deletions

View File

@@ -0,0 +1,16 @@
name: Trigger Site Update
on:
release:
types: [published]
jobs:
trigger-site:
runs-on: ubuntu-latest
steps:
- name: Send repository_dispatch to site-repo
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.SITE_REPO_TOKEN }}
repository: KotatsuApp/website
event-type: app-release

2
.idea/vcs.xml generated
View File

@@ -10,6 +10,6 @@
</option> </option>
</component> </component>
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="" vcs="Git" />
</component> </component>
</project> </project>

View File

@@ -8,19 +8,21 @@ plugins {
id 'dagger.hilt.android.plugin' id 'dagger.hilt.android.plugin'
id 'androidx.room' id 'androidx.room'
id 'org.jetbrains.kotlin.plugin.serialization' id 'org.jetbrains.kotlin.plugin.serialization'
// enable if needed
// id 'dev.reformator.stacktracedecoroutinator'
} }
android { android {
compileSdk = 35 compileSdk = 36
buildToolsVersion = '35.0.0' buildToolsVersion = '35.0.0'
namespace = 'org.koitharu.kotatsu' namespace = 'org.koitharu.kotatsu'
defaultConfig { defaultConfig {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 35 targetSdk = 36
versionCode = 1022 versionCode = 1027
versionName = '9.0' versionName = '9.1.3'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {
@@ -30,6 +32,12 @@ android {
// https://issuetracker.google.com/issues/408030127 // https://issuetracker.google.com/issues/408030127
generateLocaleConfig false generateLocaleConfig false
} }
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localProperties.load(new FileInputStream(localPropertiesFile))
}
resValue 'string', 'tg_backup_bot_token', localProperties.getProperty('tg_backup_bot_token', '')
} }
buildTypes { buildTypes {
debug { debug {
@@ -172,6 +180,7 @@ dependencies {
implementation libs.ssiv implementation libs.ssiv
implementation libs.disk.lru.cache implementation libs.disk.lru.cache
implementation libs.markwon implementation libs.markwon
implementation libs.kizzyrpc
implementation libs.acra.http implementation libs.acra.http
implementation libs.acra.dialog implementation libs.acra.dialog
@@ -179,6 +188,7 @@ dependencies {
implementation libs.conscrypt.android implementation libs.conscrypt.android
debugImplementation libs.leakcanary.android debugImplementation libs.leakcanary.android
nightlyImplementation libs.leakcanary.android
debugImplementation libs.workinspector debugImplementation libs.workinspector
testImplementation libs.junit testImplementation libs.junit

View File

@@ -56,7 +56,9 @@ class KotatsuApp : BaseApp() {
detectLeakedSqlLiteObjects() detectLeakedSqlLiteObjects()
detectLeakedClosableObjects() detectLeakedClosableObjects()
detectLeakedRegistrationObjects() detectLeakedRegistrationObjects()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectContentUriWithoutPermission() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
detectContentUriWithoutPermission()
}
detectFileUriExposure() detectFileUriExposure()
penaltyLog() penaltyLog()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {

View File

@@ -0,0 +1,64 @@
package org.koitharu.kotatsu.settings
import android.os.Bundle
import androidx.preference.Preference
import leakcanary.LeakCanary
import org.koitharu.kotatsu.KotatsuApp
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
import org.koitharu.workinspector.WorkInspector
class DebugSettingsFragment : BasePreferenceFragment(R.string.debug), Preference.OnPreferenceChangeListener,
Preference.OnPreferenceClickListener {
private val application
get() = requireContext().applicationContext as KotatsuApp
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_debug)
findPreference<SplitSwitchPreference>(KEY_LEAK_CANARY)?.let { pref ->
pref.isChecked = application.isLeakCanaryEnabled
pref.onPreferenceChangeListener = this
pref.onContainerClickListener = this
}
}
override fun onResume() {
super.onResume()
findPreference<SplitSwitchPreference>(KEY_LEAK_CANARY)?.isChecked = application.isLeakCanaryEnabled
}
override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) {
KEY_WORK_INSPECTOR -> {
startActivity(WorkInspector.getIntent(preference.context))
true
}
else -> super.onPreferenceTreeClick(preference)
}
override fun onPreferenceClick(preference: Preference): Boolean = when (preference.key) {
KEY_LEAK_CANARY -> {
startActivity(LeakCanary.newLeakDisplayActivityIntent())
true
}
else -> super.onPreferenceTreeClick(preference)
}
override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean = when (preference.key) {
KEY_LEAK_CANARY -> {
application.isLeakCanaryEnabled = newValue as Boolean
true
}
else -> false
}
private companion object {
const val KEY_LEAK_CANARY = "leak_canary"
const val KEY_WORK_INSPECTOR = "work_inspector"
}
}

View File

@@ -1,58 +0,0 @@
package org.koitharu.kotatsu.settings
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import leakcanary.LeakCanary
import org.koitharu.kotatsu.KotatsuApp
import org.koitharu.kotatsu.R
import org.koitharu.workinspector.WorkInspector
class SettingsMenuProvider(
private val context: Context,
) : MenuProvider {
private val application: KotatsuApp
get() = context.applicationContext as KotatsuApp
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_settings, menu)
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.action_leakcanary).isChecked = application.isLeakCanaryEnabled
menu.findItem(R.id.action_ssiv_debug).isChecked = SubsamplingScaleImageView.isDebug
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_leaks -> {
context.startActivity(LeakCanary.newLeakDisplayActivityIntent())
true
}
R.id.action_works -> {
context.startActivity(WorkInspector.getIntent(context))
true
}
R.id.action_leakcanary -> {
val checked = !menuItem.isChecked
menuItem.isChecked = checked
application.isLeakCanaryEnabled = checked
true
}
R.id.action_ssiv_debug -> {
val checked = !menuItem.isChecked
menuItem.isChecked = checked
SubsamplingScaleImageView.isDebug = checked
true
}
else -> false
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M20,8H17.19C16.74,7.2 16.12,6.5 15.37,6L17,4.41L15.59,3L13.42,5.17C12.96,5.06 12.5,5 12,5C11.5,5 11.05,5.06 10.59,5.17L8.41,3L7,4.41L8.62,6C7.87,6.5 7.26,7.21 6.81,8H4V10H6.09C6.03,10.33 6,10.66 6,11V12H4V14H6V15C6,15.34 6.03,15.67 6.09,16H4V18H6.81C8.47,20.87 12.14,21.84 15,20.18C15.91,19.66 16.67,18.9 17.19,18H20V16H17.91C17.97,15.67 18,15.34 18,15V14H20V12H18V11C18,10.66 17.97,10.33 17.91,10H20V8M16,15A4,4 0 0,1 12,19A4,4 0 0,1 8,15V11A4,4 0 0,1 12,7A4,4 0 0,1 16,11V15M14,10V12H10V10H14M10,14H14V16H10V14Z" />
</vector>

View File

@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<item
android:id="@+id/action_ssiv_debug"
android:checkable="true"
android:title="SSIV debug"
app:showAsAction="never"
tools:ignore="HardcodedText" />
<item
android:id="@+id/action_leakcanary"
android:checkable="true"
android:title="LeakCanary"
app:showAsAction="never"
tools:ignore="HardcodedText" />
<item
android:id="@+id/action_leaks"
android:title="@string/leak_canary_display_activity_label"
app:showAsAction="never" />
<item
android:id="@+id/action_works"
android:title="@string/wi_lib_name"
app:showAsAction="never" />
</menu>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android">
<org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
android:id="@+id/action_leakcanary"
android:key="leak_canary"
android:persistent="false"
android:title="LeakCanary" />
<Preference
android:id="@+id/action_works"
android:key="work_inspector"
android:persistent="false"
android:title="@string/wi_lib_name" />
</androidx.preference.PreferenceScreen>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.DebugSettingsFragment"
android:icon="@drawable/ic_debug"
android:key="debug"
android:title="@string/debug" />
</androidx.preference.PreferenceScreen>

View File

@@ -287,6 +287,9 @@
<data android:mimeType="image/*" /> <data android:mimeType="image/*" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name="org.koitharu.kotatsu.scrobbling.discord.ui.DiscordAuthActivity"
android:label="@string/discord" />
<service <service
android:name="androidx.work.impl.foreground.SystemForegroundService" android:name="androidx.work.impl.foreground.SystemForegroundService"
@@ -404,6 +407,13 @@
tools:node="remove" /> tools:node="remove" />
</provider> </provider>
<receiver
android:name="org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler$DiscardReceiver"
android:exported="false">
<intent-filter>
<action android:name="org.koitharu.kotatsu.CAPTCHA_DISCARD" />
</intent-filter>
</receiver>
<receiver <receiver
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider" android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider"
android:exported="true" android:exported="true"

View File

@@ -30,21 +30,19 @@ constructor(
oldManga: Manga, oldManga: Manga,
newManga: Manga, newManga: Manga,
) { ) {
val oldDetails = val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
if (oldManga.chapters.isNullOrEmpty()) { runCatchingCancellable {
runCatchingCancellable { mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga) }.getOrDefault(oldManga)
}.getOrDefault(oldManga) } else {
} else { oldManga
oldManga }
} val newDetails = if (newManga.chapters.isNullOrEmpty()) {
val newDetails = mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
if (newManga.chapters.isNullOrEmpty()) { } else {
mangaRepositoryFactory.create(newManga.source).getDetails(newManga) newManga
} else { }
newManga mangaDataRepository.storeManga(newDetails, replaceExisting = true)
}
mangaDataRepository.storeManga(newDetails)
database.withTransaction { database.withTransaction {
// replace favorites // replace favorites
val favoritesDao = database.getFavouritesDao() val favoritesDao = database.getFavouritesDao()
@@ -101,11 +99,11 @@ constructor(
mangaId = newDetails.id, mangaId = newDetails.id,
rating = prevInfo.rating, rating = prevInfo.rating,
status = status =
prevInfo.status ?: when { prevInfo.status ?: when {
newHistory == null -> ScrobblingStatus.PLANNED newHistory == null -> ScrobblingStatus.PLANNED
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
else -> ScrobblingStatus.READING else -> ScrobblingStatus.READING
}, },
comment = prevInfo.comment, comment = prevInfo.comment,
) )
if (newHistory != null) { if (newHistory != null) {

View File

@@ -17,6 +17,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase.NoAlternativesException
import org.koitharu.kotatsu.core.ErrorReporterReceiver import org.koitharu.kotatsu.core.ErrorReporterReceiver
import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.model.isNsfw
@@ -47,7 +48,7 @@ class AutoFixService : CoroutineIntentService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
notificationManager = NotificationManagerCompat.from(applicationContext) notificationManager = NotificationManagerCompat.from(this)
} }
override suspend fun IntentJobContext.processIntent(intent: Intent) { override suspend fun IntentJobContext.processIntent(intent: Intent) {
@@ -58,7 +59,7 @@ class AutoFixService : CoroutineIntentService() {
val result = runCatchingCancellable { val result = runCatchingCancellable {
autoFixUseCase.invoke(mangaId) autoFixUseCase.invoke(mangaId)
} }
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { if (checkNotificationPermission(CHANNEL_ID)) {
val notification = buildNotification(startId, result) val notification = buildNotification(startId, result)
notificationManager.notify(TAG, startId, notification) notificationManager.notify(TAG, startId, notification)
} }
@@ -67,7 +68,7 @@ class AutoFixService : CoroutineIntentService() {
} }
override fun IntentJobContext.onError(error: Throwable) { override fun IntentJobContext.onError(error: Throwable) {
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { if (checkNotificationPermission(CHANNEL_ID)) {
val notification = runBlocking { buildNotification(startId, Result.failure(error)) } val notification = runBlocking { buildNotification(startId, Result.failure(error)) }
notificationManager.notify(TAG, startId, notification) notificationManager.notify(TAG, startId, notification)
} }
@@ -75,7 +76,7 @@ class AutoFixService : CoroutineIntentService() {
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
private fun startForeground(jobContext: IntentJobContext) { private fun startForeground(jobContext: IntentJobContext) {
val title = applicationContext.getString(R.string.fixing_manga) val title = getString(R.string.fixing_manga)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN) val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
.setName(title) .setName(title)
.setShowBadge(false) .setShowBadge(false)
@@ -85,7 +86,7 @@ class AutoFixService : CoroutineIntentService() {
.build() .build()
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title) .setContentTitle(title)
.setPriority(NotificationCompat.PRIORITY_MIN) .setPriority(NotificationCompat.PRIORITY_MIN)
.setDefaults(0) .setDefaults(0)
@@ -97,7 +98,7 @@ class AutoFixService : CoroutineIntentService() {
.setCategory(NotificationCompat.CATEGORY_PROGRESS) .setCategory(NotificationCompat.CATEGORY_PROGRESS)
.addAction( .addAction(
appcompatR.drawable.abc_ic_clear_material, appcompatR.drawable.abc_ic_clear_material,
applicationContext.getString(android.R.string.cancel), getString(android.R.string.cancel),
jobContext.getCancelIntent(), jobContext.getCancelIntent(),
) )
.build() .build()
@@ -110,7 +111,7 @@ class AutoFixService : CoroutineIntentService() {
} }
private suspend fun buildNotification(startId: Int, result: Result<Pair<Manga, Manga?>>): Notification { private suspend fun buildNotification(startId: Int, result: Result<Pair<Manga, Manga?>>): Notification {
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setDefaults(0) .setDefaults(0)
.setSilent(true) .setSilent(true)
@@ -119,17 +120,17 @@ class AutoFixService : CoroutineIntentService() {
if (replacement != null) { if (replacement != null) {
notification.setLargeIcon( notification.setLargeIcon(
coil.execute( coil.execute(
ImageRequest.Builder(applicationContext) ImageRequest.Builder(this)
.data(replacement.coverUrl) .data(replacement.coverUrl)
.mangaSourceExtra(replacement.source) .mangaSourceExtra(replacement.source)
.build(), .build(),
).toBitmapOrNull(), ).toBitmapOrNull(),
) )
notification.setSubText(replacement.title) notification.setSubText(replacement.title)
val intent = AppRouter.detailsIntent(applicationContext, replacement) val intent = AppRouter.detailsIntent(this, replacement)
notification.setContentIntent( notification.setContentIntent(
PendingIntentCompat.getActivity( PendingIntentCompat.getActivity(
applicationContext, this,
replacement.id.toInt(), replacement.id.toInt(),
intent, intent,
PendingIntent.FLAG_UPDATE_CURRENT, PendingIntent.FLAG_UPDATE_CURRENT,
@@ -143,35 +144,35 @@ class AutoFixService : CoroutineIntentService() {
}, },
) )
notification notification
.setContentTitle(applicationContext.getString(R.string.fixed)) .setContentTitle(getString(R.string.fixed))
.setContentText( .setContentText(
applicationContext.getString( getString(
R.string.manga_replaced, R.string.manga_replaced,
seed.title, seed.title,
seed.source.getTitle(applicationContext), seed.source.getTitle(this),
replacement.title, replacement.title,
replacement.source.getTitle(applicationContext), replacement.source.getTitle(this),
), ),
) )
.setSmallIcon(R.drawable.ic_stat_done) .setSmallIcon(R.drawable.ic_stat_done)
} else { } else {
notification notification
.setContentTitle(applicationContext.getString(R.string.fixing_manga)) .setContentTitle(getString(R.string.fixing_manga))
.setContentText(applicationContext.getString(R.string.no_fix_required, seed.title)) .setContentText(getString(R.string.no_fix_required, seed.title))
.setSmallIcon(android.R.drawable.stat_sys_warning) .setSmallIcon(android.R.drawable.stat_sys_warning)
} }
}.onFailure { error -> }.onFailure { error ->
notification notification
.setContentTitle(applicationContext.getString(R.string.error_occurred)) .setContentTitle(getString(R.string.error_occurred))
.setContentText( .setContentText(
if (error is AutoFixUseCase.NoAlternativesException) { if (error is NoAlternativesException) {
applicationContext.getString(R.string.no_alternatives_found, error.seed.manga.title) getString(R.string.no_alternatives_found, error.seed.manga.title)
} else { } else {
error.getDisplayMessage(applicationContext.resources) error.getDisplayMessage(resources)
}, },
).setSmallIcon(android.R.drawable.stat_notify_error) ).setSmallIcon(android.R.drawable.stat_notify_error)
ErrorReporterReceiver.getNotificationAction( ErrorReporterReceiver.getNotificationAction(
context = applicationContext, context = this,
e = error, e = error,
notificationId = startId, notificationId = startId,
notificationTag = TAG, notificationTag = TAG,

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.backups.ui package org.koitharu.kotatsu.backups.ui
import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
@@ -27,24 +28,13 @@ abstract class BaseBackupRestoreService : CoroutineIntentService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
notificationManager = NotificationManagerCompat.from(applicationContext) notificationManager = NotificationManagerCompat.from(applicationContext)
createNotificationChannel() createNotificationChannel(this)
} }
override fun IntentJobContext.onError(error: Throwable) { override fun IntentJobContext.onError(error: Throwable) {
showResultNotification(null, CompositeResult.failure(error)) showResultNotification(null, CompositeResult.failure(error))
} }
private fun createNotificationChannel() {
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_HIGH)
.setName(getString(R.string.backup_restore))
.setShowBadge(true)
.setVibrationEnabled(false)
.setSound(null, null)
.setLightsEnabled(false)
.build()
notificationManager.createNotificationChannel(channel)
}
protected fun IntentJobContext.showResultNotification( protected fun IntentJobContext.showResultNotification(
fileUri: Uri?, fileUri: Uri?,
result: CompositeResult, result: CompositeResult,
@@ -128,8 +118,19 @@ abstract class BaseBackupRestoreService : CoroutineIntentService() {
.setBigContentTitle(title), .setBigContentTitle(title),
) )
protected companion object { companion object {
const val CHANNEL_ID = "backup_restore" const val CHANNEL_ID = "backup_restore"
fun createNotificationChannel(context: Context) {
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_HIGH)
.setName(context.getString(R.string.backup_restore))
.setShowBadge(true)
.setVibrationEnabled(false)
.setSound(null, null)
.setLightsEnabled(false)
.build()
NotificationManagerCompat.from(context).createNotificationChannel(channel)
}
} }
} }

View File

@@ -1,12 +1,21 @@
package org.koitharu.kotatsu.backups.ui.periodical package org.koitharu.kotatsu.backups.ui.periodical
import android.content.Intent import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.backups.data.BackupRepository import org.koitharu.kotatsu.backups.data.BackupRepository
import org.koitharu.kotatsu.backups.domain.BackupUtils import org.koitharu.kotatsu.backups.domain.BackupUtils
import org.koitharu.kotatsu.backups.domain.ExternalBackupStorage import org.koitharu.kotatsu.backups.domain.ExternalBackupStorage
import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService
import org.koitharu.kotatsu.core.ErrorReporterReceiver
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.CoroutineIntentService import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
import javax.inject.Inject import javax.inject.Inject
@@ -40,7 +49,7 @@ class PeriodicalBackupService : CoroutineIntentService() {
} }
externalBackupStorage.put(output) externalBackupStorage.put(output)
externalBackupStorage.trim(settings.periodicalBackupMaxCount) externalBackupStorage.trim(settings.periodicalBackupMaxCount)
if (settings.isBackupTelegramUploadEnabled) { if (settings.isBackupTelegramUploadEnabled && telegramBackupUploader.isAvailable) {
telegramBackupUploader.uploadBackup(output) telegramBackupUploader.uploadBackup(output)
} }
} finally { } finally {
@@ -48,5 +57,49 @@ class PeriodicalBackupService : CoroutineIntentService() {
} }
} }
override fun IntentJobContext.onError(error: Throwable) = Unit override fun IntentJobContext.onError(error: Throwable) {
if (!applicationContext.checkNotificationPermission(CHANNEL_ID)) {
return
}
BaseBackupRestoreService.createNotificationChannel(applicationContext)
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setDefaults(0)
.setSilent(true)
.setAutoCancel(true)
val title = getString(R.string.periodic_backups)
val message = getString(
R.string.inline_preference_pattern,
getString(R.string.packup_creation_failed),
error.getDisplayMessage(resources),
)
notification
.setContentText(message)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setStyle(
NotificationCompat.BigTextStyle()
.bigText(message)
.setSummaryText(getString(R.string.packup_creation_failed))
.setBigContentTitle(title),
)
ErrorReporterReceiver.getNotificationAction(applicationContext, error, startId, TAG)?.let { action ->
notification.addAction(action)
}
notification.setContentIntent(
PendingIntentCompat.getActivity(
applicationContext,
0,
AppRouter.periodicBackupSettingsIntent(applicationContext),
0,
false,
),
)
NotificationManagerCompat.from(applicationContext).notify(TAG, startId, notification.build())
}
private companion object {
const val CHANNEL_ID = BaseBackupRestoreService.CHANNEL_ID
const val TAG = "periodical_backup"
}
} }

View File

@@ -9,6 +9,7 @@ import androidx.activity.result.ActivityResultCallback
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.preference.EditTextPreference import androidx.preference.EditTextPreference
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceCategory
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -37,6 +38,7 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_backup_periodic) addPreferencesFromResource(R.xml.pref_backup_periodic)
findPreference<PreferenceCategory>(AppSettings.KEY_BACKUP_TG)?.isVisible = viewModel.isTelegramAvailable
findPreference<EditTextPreference>(AppSettings.KEY_BACKUP_TG_CHAT)?.summaryProvider = findPreference<EditTextPreference>(AppSettings.KEY_BACKUP_TG_CHAT)?.summaryProvider =
EditTextFallbackSummaryProvider(R.string.telegram_chat_id_summary) EditTextFallbackSummaryProvider(R.string.telegram_chat_id_summary)
} }
@@ -84,6 +86,11 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
"" -> null "" -> null
else -> path else -> path
} }
preference.icon = if (path == null) {
getWarningIcon()
} else {
null
}
} }
private fun bindLastBackupInfo(lastBackupDate: Date?) { private fun bindLastBackupInfo(lastBackupDate: Date?) {

View File

@@ -27,6 +27,9 @@ class PeriodicalBackupSettingsViewModel @Inject constructor(
@ApplicationContext private val appContext: Context, @ApplicationContext private val appContext: Context,
) : BaseViewModel() { ) : BaseViewModel() {
val isTelegramAvailable
get() = telegramUploader.isAvailable
val lastBackupDate = MutableStateFlow<Date?>(null) val lastBackupDate = MutableStateFlow<Date?>(null)
val backupsDirectory = MutableStateFlow<String?>("") val backupsDirectory = MutableStateFlow<String?>("")
val isTelegramCheckLoading = MutableStateFlow(false) val isTelegramCheckLoading = MutableStateFlow(false)

View File

@@ -30,6 +30,9 @@ class TelegramBackupUploader @Inject constructor(
private val botToken = context.getString(R.string.tg_backup_bot_token) private val botToken = context.getString(R.string.tg_backup_bot_token)
val isAvailable: Boolean
get() = botToken.isNotEmpty()
suspend fun uploadBackup(file: File) { suspend fun uploadBackup(file: File) {
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull()) val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
val multipartBody = MultipartBody.Builder() val multipartBody = MultipartBody.Builder()

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.browser package org.koitharu.kotatsu.browser
import android.annotation.SuppressLint
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Looper import android.os.Looper
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
@@ -15,7 +16,7 @@ import java.io.ByteArrayInputStream
open class BrowserClient( open class BrowserClient(
private val callback: BrowserCallback, private val callback: BrowserCallback,
private val adBlock: AdBlock, private val adBlock: AdBlock?,
) : WebViewClient() { ) : WebViewClient() {
/** /**
@@ -47,7 +48,7 @@ open class BrowserClient(
override fun shouldInterceptRequest( override fun shouldInterceptRequest(
view: WebView?, view: WebView?,
url: String? url: String?
): WebResourceResponse? = if (url.isNullOrEmpty() || adBlock.shouldLoadUrl(url, view?.getUrlSafe())) { ): WebResourceResponse? = if (url.isNullOrEmpty() || adBlock?.shouldLoadUrl(url, view?.getUrlSafe()) ?: true) {
super.shouldInterceptRequest(view, url) super.shouldInterceptRequest(view, url)
} else { } else {
emptyResponse() emptyResponse()
@@ -57,15 +58,17 @@ open class BrowserClient(
override fun shouldInterceptRequest( override fun shouldInterceptRequest(
view: WebView?, view: WebView?,
request: WebResourceRequest? request: WebResourceRequest?
): WebResourceResponse? = if (request == null || adBlock.shouldLoadUrl(request.url.toString(), view?.getUrlSafe())) { ): WebResourceResponse? =
super.shouldInterceptRequest(view, request) if (request == null || adBlock?.shouldLoadUrl(request.url.toString(), view?.getUrlSafe()) ?: true) {
} else { super.shouldInterceptRequest(view, request)
emptyResponse() } else {
} emptyResponse()
}
private fun emptyResponse(): WebResourceResponse = private fun emptyResponse(): WebResourceResponse =
WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(byteArrayOf())) WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(byteArrayOf()))
@SuppressLint("WrongThread")
@AnyThread @AnyThread
private fun WebView.getUrlSafe(): String? = if (Looper.myLooper() == Looper.getMainLooper()) { private fun WebView.getUrlSafe(): String? = if (Looper.myLooper() == Looper.getMainLooper()) {
url url

View File

@@ -42,18 +42,21 @@ import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.util.AcraScreenLogger import org.koitharu.kotatsu.core.util.AcraScreenLogger
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.connectivityManager import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageFetcher import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageFetcher
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.FaviconCache
import org.koitharu.kotatsu.local.data.LocalStorageCache
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.PageCache
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
@@ -101,7 +104,7 @@ interface AppModule {
fun provideCoil( fun provideCoil(
@LocalizedAppContext context: Context, @LocalizedAppContext context: Context,
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>, @MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
mangaRepositoryFactory: MangaRepository.Factory, faviconFetcherFactory: FaviconFetcher.Factory,
imageProxyInterceptor: ImageProxyInterceptor, imageProxyInterceptor: ImageProxyInterceptor,
pageFetcherFactory: MangaPageFetcher.Factory, pageFetcherFactory: MangaPageFetcher.Factory,
coverRestoreInterceptor: CoverRestoreInterceptor, coverRestoreInterceptor: CoverRestoreInterceptor,
@@ -138,7 +141,7 @@ interface AppModule {
add(SvgDecoder.Factory()) add(SvgDecoder.Factory())
add(CbzFetcher.Factory()) add(CbzFetcher.Factory())
add(AvifImageDecoder.Factory()) add(AvifImageDecoder.Factory())
add(FaviconFetcher.Factory(mangaRepositoryFactory)) add(faviconFetcherFactory)
add(MangaPageKeyer()) add(MangaPageKeyer())
add(pageFetcherFactory) add(pageFetcherFactory)
add(imageProxyInterceptor) add(imageProxyInterceptor)
@@ -195,5 +198,29 @@ interface AppModule {
fun provideWorkManager( fun provideWorkManager(
@ApplicationContext context: Context, @ApplicationContext context: Context,
): WorkManager = WorkManager.getInstance(context) ): WorkManager = WorkManager.getInstance(context)
@Provides
@Singleton
@PageCache
fun providePageCache(
@ApplicationContext context: Context,
) = LocalStorageCache(
context = context,
dir = CacheDir.PAGES,
defaultSize = FileSize.MEGABYTES.convert(200, FileSize.BYTES),
minSize = FileSize.MEGABYTES.convert(20, FileSize.BYTES),
)
@Provides
@Singleton
@FaviconCache
fun provideFaviconCache(
@ApplicationContext context: Context,
) = LocalStorageCache(
context = context,
dir = CacheDir.FAVICONS,
defaultSize = FileSize.MEGABYTES.convert(8, FileSize.BYTES),
minSize = FileSize.MEGABYTES.convert(2, FileSize.BYTES),
)
} }
} }

View File

@@ -4,7 +4,6 @@ import android.app.PendingIntent
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.BadParcelableException
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat import androidx.core.app.PendingIntentCompat
@@ -65,7 +64,7 @@ class ErrorReporterReceiver : BroadcastReceiver() {
e: Throwable, e: Throwable,
notificationId: Int, notificationId: Int,
notificationTag: String?, notificationTag: String?,
): PendingIntent? = try { ): PendingIntent? = runCatching {
val intent = Intent(context, ErrorReporterReceiver::class.java) val intent = Intent(context, ErrorReporterReceiver::class.java)
intent.setAction(ACTION_REPORT) intent.setAction(ACTION_REPORT)
intent.setData("err://${e.hashCode()}".toUri()) intent.setData("err://${e.hashCode()}".toUri())
@@ -73,9 +72,9 @@ class ErrorReporterReceiver : BroadcastReceiver() {
intent.putExtra(EXTRA_NOTIFICATION_ID, notificationId) intent.putExtra(EXTRA_NOTIFICATION_ID, notificationId)
intent.putExtra(EXTRA_NOTIFICATION_TAG, notificationTag) intent.putExtra(EXTRA_NOTIFICATION_TAG, notificationTag)
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false) PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
} catch (e: BadParcelableException) { }.onFailure { e ->
// probably cannot write exception as serializable
e.printStackTraceDebug() e.printStackTraceDebug()
null }.getOrNull()
}
} }
} }

View File

@@ -5,7 +5,6 @@ import android.app.Notification
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.os.Build import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.annotation.RequiresPermission import androidx.annotation.RequiresPermission
@@ -14,7 +13,6 @@ import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.coroutineScope import androidx.lifecycle.coroutineScope
import coil3.EventListener import coil3.EventListener
@@ -26,6 +24,7 @@ import coil3.request.allowConversionToBitmap
import coil3.request.allowHardware import coil3.request.allowHardware
import coil3.request.lifecycle import coil3.request.lifecycle
import coil3.size.Scale import coil3.size.Scale
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@@ -55,6 +54,7 @@ import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import org.koitharu.kotatsu.parsers.util.mapToArray
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
@@ -70,22 +70,6 @@ class CaptchaHandler @Inject constructor(
private val exceptionMap = MutableScatterMap<MangaSource, CloudFlareProtectedException>() private val exceptionMap = MutableScatterMap<MangaSource, CloudFlareProtectedException>()
private val mutex = Mutex() private val mutex = Mutex()
init {
ContextCompat.registerReceiver(
context,
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val sourceName = intent?.getStringExtra(AppRouter.KEY_SOURCE) ?: return
goAsync {
handleException(MangaSource(sourceName), exception = null, notify = false)
}
}
},
IntentFilter().apply { addAction(ACTION_DISCARD) },
ContextCompat.RECEIVER_NOT_EXPORTED,
)
}
suspend fun handle(exception: CloudFlareException): Boolean = handleException(exception.source, exception, true) suspend fun handle(exception: CloudFlareException): Boolean = handleException(exception.source, exception, true)
suspend fun discard(source: MangaSource) { suspend fun discard(source: MangaSource) {
@@ -121,14 +105,14 @@ class CaptchaHandler @Inject constructor(
val dao = databaseProvider.get().getSourcesDao() val dao = databaseProvider.get().getSourcesDao()
dao.setCfState(source.name, exception?.state ?: CloudFlareHelper.PROTECTION_NOT_DETECTED) dao.setCfState(source.name, exception?.state ?: CloudFlareHelper.PROTECTION_NOT_DETECTED)
val exceptions = dao.findAllCaptchaRequired().mapNotNull {
it.source.toMangaSourceOrNull()
}.filterNot {
SourceSettings(context, it).isCaptchaNotificationsDisabled
}.mapNotNull {
exceptionMap[it]
}
if (notify && context.checkNotificationPermission(CHANNEL_ID)) { if (notify && context.checkNotificationPermission(CHANNEL_ID)) {
val exceptions = dao.findAllCaptchaRequired().mapNotNull {
it.source.toMangaSourceOrNull()
}.filterNot {
SourceSettings(context, it).isCaptchaNotificationsDisabled
}.mapNotNull {
exceptionMap[it]
}
if (removedException != null) { if (removedException != null) {
NotificationManagerCompat.from(context).cancel(TAG, removedException.source.hashCode()) NotificationManagerCompat.from(context).cancel(TAG, removedException.source.hashCode())
} }
@@ -169,6 +153,15 @@ class CaptchaHandler @Inject constructor(
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setSmallIcon(R.drawable.ic_bot) .setSmallIcon(R.drawable.ic_bot)
.setGroup(GROUP_CAPTCHA) .setGroup(GROUP_CAPTCHA)
.setContentIntent(
PendingIntentCompat.getActivities(
context, GROUP_NOTIFICATION_ID,
exceptions.mapToArray { e ->
AppRouter.cloudFlareResolveIntent(context, e)
},
0, false,
),
)
.setContentText( .setContentText(
context.getString( context.getString(
R.string.captcha_required_summary, context.getString(R.string.app_name), R.string.captcha_required_summary, context.getString(R.string.app_name),
@@ -189,7 +182,6 @@ class CaptchaHandler @Inject constructor(
private suspend fun buildNotification(exception: CloudFlareProtectedException): Notification { private suspend fun buildNotification(exception: CloudFlareProtectedException): Notification {
val intent = AppRouter.cloudFlareResolveIntent(context, exception) val intent = AppRouter.cloudFlareResolveIntent(context, exception)
.setData(exception.url.toUri())
val discardIntent = Intent(ACTION_DISCARD) val discardIntent = Intent(ACTION_DISCARD)
.putExtra(AppRouter.KEY_SOURCE, exception.source.name) .putExtra(AppRouter.KEY_SOURCE, exception.source.name)
.setData("source://${exception.source.name}".toUri()) .setData("source://${exception.source.name}".toUri())
@@ -242,6 +234,7 @@ class CaptchaHandler @Inject constructor(
.data(source.faviconUri()) .data(source.faviconUri())
.allowHardware(false) .allowHardware(false)
.allowConversionToBitmap(true) .allowConversionToBitmap(true)
.ignoreCaptchaErrors()
.mangaSourceExtra(source) .mangaSourceExtra(source)
.size(context.resources.getNotificationIconSize()) .size(context.resources.getNotificationIconSize())
.scale(Scale.FILL) .scale(Scale.FILL)
@@ -251,6 +244,20 @@ class CaptchaHandler @Inject constructor(
it.printStackTraceDebug() it.printStackTraceDebug()
}.getOrNull() }.getOrNull()
@AndroidEntryPoint
class DiscardReceiver : BroadcastReceiver() {
@Inject
lateinit var captchaHandler: CaptchaHandler
override fun onReceive(context: Context?, intent: Intent?) {
val sourceName = intent?.getStringExtra(AppRouter.KEY_SOURCE) ?: return
goAsync {
captchaHandler.handleException(MangaSource(sourceName), exception = null, notify = false)
}
}
}
companion object { companion object {
fun ImageRequest.Builder.ignoreCaptchaErrors() = apply { fun ImageRequest.Builder.ignoreCaptchaErrors() = apply {

View File

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

View File

@@ -55,6 +55,7 @@ val MangaState.titleResId: Int
MangaState.ABANDONED -> R.string.state_abandoned MangaState.ABANDONED -> R.string.state_abandoned
MangaState.PAUSED -> R.string.state_paused MangaState.PAUSED -> R.string.state_paused
MangaState.UPCOMING -> R.string.state_upcoming MangaState.UPCOMING -> R.string.state_upcoming
MangaState.RESTRICTED -> R.string.unavailable
} }
@get:DrawableRes @get:DrawableRes
@@ -65,6 +66,7 @@ val MangaState.iconResId: Int
MangaState.ABANDONED -> R.drawable.ic_state_abandoned MangaState.ABANDONED -> R.drawable.ic_state_abandoned
MangaState.PAUSED -> R.drawable.ic_action_pause MangaState.PAUSED -> R.drawable.ic_action_pause
MangaState.UPCOMING -> materialR.drawable.ic_clock_black_24dp MangaState.UPCOMING -> materialR.drawable.ic_clock_black_24dp
MangaState.RESTRICTED -> R.drawable.ic_disable
} }
@get:StringRes @get:StringRes

View File

@@ -281,6 +281,10 @@ class AppRouter private constructor(
startActivity(sourcesSettingsIntent(contextOrNull() ?: return)) startActivity(sourcesSettingsIntent(contextOrNull() ?: return))
} }
fun openDiscordSettings() {
startActivity(discordSettingsIntent(contextOrNull() ?: return))
}
fun openReaderTapGridSettings() = startActivity(ReaderTapGridConfigActivity::class.java) fun openReaderTapGridSettings() = startActivity(ReaderTapGridConfigActivity::class.java)
fun openScrobblerSettings(scrobbler: ScrobblerService) { fun openScrobblerSettings(scrobbler: ScrobblerService) {
@@ -571,7 +575,7 @@ class AppRouter private constructor(
/** Public utils **/ /** Public utils **/
fun isFilterSupported(): Boolean = when { fun isFilterSupported(): Boolean = when {
fragment != null -> fragment.activity is FilterCoordinator.Owner fragment != null -> FilterCoordinator.find(fragment) != null
activity != null -> activity is FilterCoordinator.Owner activity != null -> activity is FilterCoordinator.Owner
else -> false else -> false
} }
@@ -741,6 +745,14 @@ class AppRouter private constructor(
Intent(context, SettingsActivity::class.java) Intent(context, SettingsActivity::class.java)
.setAction(ACTION_TRACKER) .setAction(ACTION_TRACKER)
fun periodicBackupSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_PERIODIC_BACKUP)
fun discordSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_MANAGE_DISCORD)
fun proxySettingsIntent(context: Context) = fun proxySettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java) Intent(context, SettingsActivity::class.java)
.setAction(ACTION_PROXY) .setAction(ACTION_PROXY)
@@ -800,6 +812,7 @@ class AppRouter private constructor(
const val KEY_FILTER = "filter" const val KEY_FILTER = "filter"
const val KEY_ID = "id" const val KEY_ID = "id"
const val KEY_INDEX = "index" const val KEY_INDEX = "index"
const val KEY_IS_BOTTOMTAB = "is_btab"
const val KEY_KIND = "kind" const val KEY_KIND = "kind"
const val KEY_LIST_SECTION = "list_section" const val KEY_LIST_SECTION = "list_section"
const val KEY_MANGA = "manga" const val KEY_MANGA = "manga"
@@ -823,8 +836,10 @@ class AppRouter private constructor(
const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS" const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS" const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
const val ACTION_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES" const val ACTION_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES"
const val ACTION_MANAGE_DISCORD = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DISCORD"
const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS" const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS"
const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER" const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER"
const val ACTION_PERIODIC_BACKUP = "${BuildConfig.APPLICATION_ID}.action.MANAGE_PERIODIC_BACKUP"
private const val ACCOUNT_KEY = "account" private const val ACCOUNT_KEY = "account"
private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS" private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS"

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.network package org.koitharu.kotatsu.core.network
import android.util.Log
import dagger.Lazy import dagger.Lazy
import okhttp3.Headers import okhttp3.Headers
import okhttp3.Interceptor import okhttp3.Interceptor
@@ -36,7 +35,8 @@ class CommonHeadersInterceptor @Inject constructor(
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
} else { } else {
if (BuildConfig.DEBUG && source == null) { if (BuildConfig.DEBUG && source == null) {
Log.w("Http", "Request without source tag: ${request.url}") IllegalArgumentException("Request without source tag: ${request.url}")
.printStackTrace()
} }
null null
} }

View File

@@ -0,0 +1,58 @@
package org.koitharu.kotatsu.core.network.webview
import android.content.Context
import android.webkit.WebView
import androidx.annotation.MainThread
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import java.lang.ref.WeakReference
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Singleton
class WebViewExecutor @Inject constructor(
@ApplicationContext private val context: Context
) {
private var webViewCached: WeakReference<WebView>? = null
private val mutex = Mutex()
suspend fun evaluateJs(baseUrl: String?, script: String): String? = mutex.withLock {
withContext(Dispatchers.Main.immediate) {
val webView = obtainWebView()
if (!baseUrl.isNullOrEmpty()) {
suspendCoroutine { cont ->
webView.webViewClient = ContinuationResumeWebViewClient(cont)
webView.loadDataWithBaseURL(baseUrl, " ", "text/html", null, null)
}
}
suspendCoroutine { cont ->
webView.evaluateJavascript(script) { result ->
cont.resume(result?.takeUnless { it == "null" })
}
}
}
}
@MainThread
fun getDefaultUserAgent() = runCatching {
obtainWebView().settings.userAgentString.sanitizeHeaderValue().trim().nullIfEmpty()
}.onFailure { e ->
e.printStackTraceDebug()
}.getOrNull()
@MainThread
private fun obtainWebView(): WebView = webViewCached?.get() ?: WebView(context).also {
it.configureForParser(null)
webViewCached = WeakReference(it)
}
}

View File

@@ -149,7 +149,7 @@ class AppShortcutManager @Inject constructor(
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) }, onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) }, onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
) )
mangaRepository.storeManga(manga) mangaRepository.storeManga(manga, replaceExisting = true)
val title = manga.title.ifEmpty { val title = manga.title.ifEmpty {
manga.altTitles.firstOrNull() manga.altTitles.firstOrNull()
}.ifNullOrEmpty { }.ifNullOrEmpty {
@@ -173,6 +173,7 @@ class AppShortcutManager @Inject constructor(
coil.execute( coil.execute(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(source.faviconUri()) .data(source.faviconUri())
.mangaSourceExtra(source)
.size(iconSize) .size(iconSize)
.scale(Scale.FIT) .scale(Scale.FIT)
.build(), .build(),

View File

@@ -13,7 +13,6 @@ import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
// https://stackoverflow.com/questions/77555641/saf-no-activity-found-to-handle-intent-android-intent-action-open-document-tr // https://stackoverflow.com/questions/77555641/saf-no-activity-found-to-handle-intent-android-intent-action-open-document-tr
class OpenDocumentTreeHelper( class OpenDocumentTreeHelper(
@@ -28,38 +27,42 @@ class OpenDocumentTreeHelper(
callback, callback,
) )
private val pickFileTreeLauncherQ = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { private val pickFileTreeLauncherPrimaryStorage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
activityResultCaller.registerForActivityResult(OpenDocumentTreeContractQ(flags), callback) activityResultCaller.registerForActivityResult(OpenDocumentTreeContractPrimaryStorage(flags), callback)
} else { } else {
null null
} }
private val pickFileTreeLauncherLegacy = activityResultCaller.registerForActivityResult( private val pickFileTreeLauncherDefault = activityResultCaller.registerForActivityResult(
contract = OpenDocumentTreeContractLegacy(flags), contract = OpenDocumentTreeContractDefault(flags),
callback = callback, callback = callback,
) )
override fun launch(input: Uri?, options: ActivityOptionsCompat?) { override fun launch(input: Uri?, options: ActivityOptionsCompat?) {
if (pickFileTreeLauncherQ == null) {
pickFileTreeLauncherLegacy.launch(input, options)
return
}
try { try {
pickFileTreeLauncherQ.launch(input, options) pickFileTreeLauncherDefault.launch(input, options)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTraceDebug() if (pickFileTreeLauncherPrimaryStorage != null) {
pickFileTreeLauncherLegacy.launch(input, options) try {
pickFileTreeLauncherPrimaryStorage.launch(input, options)
} catch (e2: Exception) {
e.addSuppressed(e2)
throw e
}
} else {
throw e
}
} }
} }
override fun unregister() { override fun unregister() {
pickFileTreeLauncherQ?.unregister() pickFileTreeLauncherPrimaryStorage?.unregister()
pickFileTreeLauncherLegacy.unregister() pickFileTreeLauncherDefault.unregister()
} }
override val contract: ActivityResultContract<Uri?, *> override val contract: ActivityResultContract<Uri?, *>
get() = pickFileTreeLauncherQ?.contract ?: pickFileTreeLauncherLegacy.contract get() = pickFileTreeLauncherPrimaryStorage?.contract ?: pickFileTreeLauncherDefault.contract
private open class OpenDocumentTreeContractLegacy( private open class OpenDocumentTreeContractDefault(
private val flags: Int, private val flags: Int,
) : ActivityResultContracts.OpenDocumentTree() { ) : ActivityResultContracts.OpenDocumentTree() {
@@ -71,9 +74,9 @@ class OpenDocumentTreeHelper(
} }
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
private class OpenDocumentTreeContractQ( private class OpenDocumentTreeContractPrimaryStorage(
private val flags: Int, private val flags: Int,
) : OpenDocumentTreeContractLegacy(flags) { ) : OpenDocumentTreeContractDefault(flags) {
override fun createIntent(context: Context, input: Uri?): Intent { override fun createIntent(context: Context, input: Uri?): Intent {
val intent = (context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager) val intent = (context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager)

View File

@@ -6,12 +6,12 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.AbstractMangaParser import org.koitharu.kotatsu.parsers.core.AbstractMangaParser
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
import java.util.EnumSet import java.util.EnumSet
/** /**
@@ -25,14 +25,18 @@ class DummyParser(context: MangaLoaderContext) : AbstractMangaParser(context, Ma
override val availableSortOrders: Set<SortOrder> override val availableSortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java) get() = EnumSet.allOf(SortOrder::class.java)
override val searchQueryCapabilities: MangaSearchQueryCapabilities override val filterCapabilities: MangaListFilterCapabilities
get() = MangaSearchQueryCapabilities() get() = MangaListFilterCapabilities()
override suspend fun getDetails(manga: Manga): Manga = stub(manga) override suspend fun getDetails(manga: Manga): Manga = stub(manga)
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null) override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
override suspend fun getList(query: MangaSearchQuery): List<Manga> = stub(null) override suspend fun getList(
offset: Int,
order: SortOrder,
filter: MangaListFilter
): List<Manga> = stub(null)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null) override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)

View File

@@ -41,7 +41,7 @@ class MangaDataRepository @Inject constructor(
suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) { suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) {
db.withTransaction { db.withTransaction {
storeManga(manga) storeManga(manga, replaceExisting = false)
val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id) val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id)
db.getPreferencesDao().upsert(entity.copy(mode = mode.id)) db.getPreferencesDao().upsert(entity.copy(mode = mode.id))
} }
@@ -49,7 +49,7 @@ class MangaDataRepository @Inject constructor(
suspend fun saveColorFilter(manga: Manga, colorFilter: ReaderColorFilter?) { suspend fun saveColorFilter(manga: Manga, colorFilter: ReaderColorFilter?) {
db.withTransaction { db.withTransaction {
storeManga(manga) storeManga(manga, replaceExisting = false)
val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id) val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id)
db.getPreferencesDao().upsert( db.getPreferencesDao().upsert(
entity.copy( entity.copy(
@@ -87,10 +87,11 @@ class MangaDataRepository @Inject constructor(
return map return map
} }
suspend fun setOverride(mangaId: Long, override: MangaOverride?) { suspend fun setOverride(manga: Manga, override: MangaOverride?) {
db.withTransaction { db.withTransaction {
storeManga(manga, replaceExisting = false)
val dao = db.getPreferencesDao() val dao = db.getPreferencesDao()
val entity = dao.find(mangaId) ?: newEntity(mangaId) val entity = dao.find(manga.id) ?: newEntity(manga.id)
dao.upsert( dao.upsert(
entity.copy( entity.copy(
titleOverride = override?.title?.nullIfEmpty(), titleOverride = override?.title?.nullIfEmpty(),
@@ -127,7 +128,10 @@ class MangaDataRepository @Inject constructor(
else -> null else -> null
} }
suspend fun storeManga(manga: Manga) { suspend fun storeManga(manga: Manga, replaceExisting: Boolean) {
if (!replaceExisting && db.getMangaDao().find(manga.id) != null) {
return
}
db.withTransaction { db.withTransaction {
// avoid storing local manga if remote one is already stored // avoid storing local manga if remote one is already stored
val existing = if (manga.isLocal) { val existing = if (manga.isLocal) {
@@ -185,7 +189,7 @@ class MangaDataRepository @Inject constructor(
emitInitialState = emitInitialState, emitInitialState = emitInitialState,
) )
private suspend fun Manga.withCachedChaptersIfNeeded(flag: Boolean): Manga = if (flag && chapters.isNullOrEmpty()) { private suspend fun Manga.withCachedChaptersIfNeeded(flag: Boolean): Manga = if (flag && !isLocal && chapters.isNullOrEmpty()) {
val cachedChapters = db.getChaptersDao().findAll(id) val cachedChapters = db.getChaptersDao().findAll(id)
if (cachedChapters.isEmpty()) { if (cachedChapters.isEmpty()) {
this this

View File

@@ -3,15 +3,10 @@ package org.koitharu.kotatsu.core.parser
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.util.Base64 import android.util.Base64
import android.webkit.WebView
import androidx.annotation.MainThread
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -22,11 +17,8 @@ import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
import org.koitharu.kotatsu.core.image.BitmapDecoderCompat import org.koitharu.kotatsu.core.image.BitmapDecoderCompat
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.network.webview.ContinuationResumeWebViewClient import org.koitharu.kotatsu.core.network.webview.WebViewExecutor
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
import org.koitharu.kotatsu.core.util.ext.toList import org.koitharu.kotatsu.core.util.ext.toList
import org.koitharu.kotatsu.core.util.ext.toMimeType import org.koitharu.kotatsu.core.util.ext.toMimeType
import org.koitharu.kotatsu.core.util.ext.use import org.koitharu.kotatsu.core.util.ext.use
@@ -37,25 +29,21 @@ import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.UserAgents import org.koitharu.kotatsu.parsers.network.UserAgents
import org.koitharu.kotatsu.parsers.util.map import org.koitharu.kotatsu.parsers.util.map
import java.lang.ref.WeakReference
import java.util.Locale import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Singleton @Singleton
class MangaLoaderContextImpl @Inject constructor( class MangaLoaderContextImpl @Inject constructor(
@MangaHttpClient override val httpClient: OkHttpClient, @MangaHttpClient override val httpClient: OkHttpClient,
override val cookieJar: MutableCookieJar, override val cookieJar: MutableCookieJar,
@ApplicationContext private val androidContext: Context, @ApplicationContext private val androidContext: Context,
private val webViewExecutor: WebViewExecutor,
) : MangaLoaderContext() { ) : MangaLoaderContext() {
private var webViewCached: WeakReference<WebView>? = null
private val webViewUserAgent by lazy { obtainWebViewUserAgent() } private val webViewUserAgent by lazy { obtainWebViewUserAgent() }
private val jsMutex = Mutex()
private val jsTimeout = TimeUnit.SECONDS.toMillis(4) private val jsTimeout = TimeUnit.SECONDS.toMillis(4)
@Deprecated("Provide a base url") @Deprecated("Provide a base url")
@@ -63,22 +51,7 @@ class MangaLoaderContextImpl @Inject constructor(
override suspend fun evaluateJs(script: String): String? = evaluateJs("", script) override suspend fun evaluateJs(script: String): String? = evaluateJs("", script)
override suspend fun evaluateJs(baseUrl: String, script: String): String? = withTimeout(jsTimeout) { override suspend fun evaluateJs(baseUrl: String, script: String): String? = withTimeout(jsTimeout) {
jsMutex.withLock { webViewExecutor.evaluateJs(baseUrl, script)
withContext(Dispatchers.Main.immediate) {
val webView = obtainWebView()
if (baseUrl.isNotEmpty()) {
suspendCoroutine { cont ->
webView.webViewClient = ContinuationResumeWebViewClient(cont)
webView.loadDataWithBaseURL(baseUrl, " ", "text/html", null, null)
}
}
suspendCoroutine { cont ->
webView.evaluateJavascript(script) { result ->
cont.resume(result?.takeUnless { it == "null" })
}
}
}
}
} }
override fun getDefaultUserAgent(): String = webViewUserAgent override fun getDefaultUserAgent(): String = webViewUserAgent
@@ -119,27 +92,14 @@ class MangaLoaderContextImpl @Inject constructor(
override fun createBitmap(width: Int, height: Int): Bitmap = BitmapWrapper.create(width, height) override fun createBitmap(width: Int, height: Int): Bitmap = BitmapWrapper.create(width, height)
@MainThread
private fun obtainWebView(): WebView = webViewCached?.get() ?: WebView(androidContext).also {
it.configureForParser(null)
webViewCached = WeakReference(it)
}
private fun obtainWebViewUserAgent(): String { private fun obtainWebViewUserAgent(): String {
val mainDispatcher = Dispatchers.Main.immediate val mainDispatcher = Dispatchers.Main.immediate
return if (!mainDispatcher.isDispatchNeeded(EmptyCoroutineContext)) { return if (!mainDispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
obtainWebViewUserAgentImpl() webViewExecutor.getDefaultUserAgent()
} else { } else {
runBlocking(mainDispatcher) { runBlocking(mainDispatcher) {
obtainWebViewUserAgentImpl() webViewExecutor.getDefaultUserAgent()
} }
} } ?: UserAgents.FIREFOX_MOBILE
} }
@MainThread
private fun obtainWebViewUserAgentImpl() = runCatching {
obtainWebView().settings.userAgentString.sanitizeHeaderValue()
}.onFailure { e ->
e.printStackTraceDebug()
}.getOrDefault(UserAgents.FIREFOX_MOBILE)
} }

View File

@@ -53,6 +53,9 @@ class ExternalPluginContentSource(
filter.states.forEach { uri.appendQueryParameter("state", it.name) } filter.states.forEach { uri.appendQueryParameter("state", it.name) }
filter.locale?.let { uri.appendQueryParameter("locale", it.language) } filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) } filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
if (!filter.author.isNullOrEmpty()) {
uri.appendQueryParameter("author", filter.author)
}
if (!filter.query.isNullOrEmpty()) { if (!filter.query.isNullOrEmpty()) {
uri.appendQueryParameter("query", filter.query) uri.appendQueryParameter("query", filter.query)
} }
@@ -196,6 +199,7 @@ class ExternalPluginContentSource(
isYearSupported = cursor.getBooleanOrDefault(COLUMN_YEAR, false), isYearSupported = cursor.getBooleanOrDefault(COLUMN_YEAR, false),
isYearRangeSupported = cursor.getBooleanOrDefault(COLUMN_YEAR_RANGE, false), isYearRangeSupported = cursor.getBooleanOrDefault(COLUMN_YEAR_RANGE, false),
isOriginalLocaleSupported = cursor.getBooleanOrDefault(COLUMN_ORIGINAL_LOCALE, false), isOriginalLocaleSupported = cursor.getBooleanOrDefault(COLUMN_ORIGINAL_LOCALE, false),
isAuthorSearchSupported = cursor.getBooleanOrDefault(COLUMN_AUTHOR, false),
), ),
) )
} else { } else {

View File

@@ -10,15 +10,20 @@ import coil3.ColorImage
import coil3.ImageLoader import coil3.ImageLoader
import coil3.asImage import coil3.asImage
import coil3.decode.DataSource import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult import coil3.fetch.FetchResult
import coil3.fetch.Fetcher import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult import coil3.fetch.ImageFetchResult
import coil3.fetch.SourceFetchResult
import coil3.request.Options import coil3.request.Options
import coil3.size.pxOrElse import coil3.size.pxOrElse
import coil3.toAndroidUri import coil3.toAndroidUri
import coil3.toBitmap
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okio.FileSystem
import okio.IOException import okio.IOException
import okio.Path.Companion.toOkioPath
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
@@ -26,8 +31,16 @@ import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.ext.fetch import org.koitharu.kotatsu.core.util.ext.fetch
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
import org.koitharu.kotatsu.local.data.FaviconCache
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageCache
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File
import javax.inject.Inject
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
import coil3.Uri as CoilUri import coil3.Uri as CoilUri
@@ -36,6 +49,7 @@ class FaviconFetcher(
private val options: Options, private val options: Options,
private val imageLoader: ImageLoader, private val imageLoader: ImageLoader,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val localStorageCache: LocalStorageCache,
) : Fetcher { ) : Fetcher {
override suspend fun fetch(): FetchResult? { override suspend fun fetch(): FetchResult? {
@@ -61,6 +75,16 @@ class FaviconFetcher(
options.size.width.pxOrElse { FALLBACK_SIZE }, options.size.width.pxOrElse { FALLBACK_SIZE },
options.size.height.pxOrElse { FALLBACK_SIZE }, options.size.height.pxOrElse { FALLBACK_SIZE },
) )
val cacheKey = options.diskCacheKey ?: "${repository.source.name}_$sizePx"
if (options.diskCachePolicy.readEnabled) {
localStorageCache[cacheKey]?.let { file ->
return SourceFetchResult(
source = ImageSource(file.toOkioPath(), FileSystem.SYSTEM),
mimeType = MimeTypes.probeMimeType(file)?.toString(),
dataSource = DataSource.DISK,
)
}
}
var favicons = repository.getFavicons() var favicons = repository.getFavicons()
var lastError: Exception? = null var lastError: Exception? = null
while (favicons.isNotEmpty()) { while (favicons.isNotEmpty()) {
@@ -69,7 +93,11 @@ class FaviconFetcher(
try { try {
val result = imageLoader.fetch(icon.url, options) val result = imageLoader.fetch(icon.url, options)
if (result != null) { if (result != null) {
return result return if (options.diskCachePolicy.writeEnabled) {
writeToCache(cacheKey, result)
} else {
result
}
} else { } else {
favicons -= icon favicons -= icon
} }
@@ -97,8 +125,39 @@ class FaviconFetcher(
) )
} }
class Factory( private suspend fun writeToCache(key: String, result: FetchResult): FetchResult = runCatchingCancellable {
when (result) {
is ImageFetchResult -> {
if (result.dataSource == DataSource.NETWORK) {
localStorageCache.set(key, result.image.toBitmap()).asFetchResult()
} else {
result
}
}
is SourceFetchResult -> {
if (result.dataSource == DataSource.NETWORK) {
result.source.source().use {
localStorageCache.set(key, it, result.mimeType?.toMimeTypeOrNull()).asFetchResult()
}
} else {
result
}
}
}
}.onFailure {
it.printStackTraceDebug()
}.getOrDefault(result)
private fun File.asFetchResult() = SourceFetchResult(
source = ImageSource(toOkioPath(), FileSystem.SYSTEM),
mimeType = MimeTypes.probeMimeType(this)?.toString(),
dataSource = DataSource.DISK,
)
class Factory @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
@FaviconCache private val faviconCache: LocalStorageCache,
) : Fetcher.Factory<CoilUri> { ) : Fetcher.Factory<CoilUri> {
override fun create( override fun create(
@@ -106,7 +165,7 @@ class FaviconFetcher(
options: Options, options: Options,
imageLoader: ImageLoader imageLoader: ImageLoader
): Fetcher? = if (data.scheme == URI_SCHEME_FAVICON) { ): Fetcher? = if (data.scheme == URI_SCHEME_FAVICON) {
FaviconFetcher(data.toAndroidUri(), options, imageLoader, mangaRepositoryFactory) FaviconFetcher(data.toAndroidUri(), options, imageLoader, mangaRepositoryFactory, faviconCache)
} else { } else {
null null
} }

View File

@@ -15,12 +15,17 @@ import androidx.core.os.LocaleListCompat
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.core.util.ext.connectivityManager import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.getEnumValue import org.koitharu.kotatsu.core.util.ext.getEnumValue
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeChanges
import org.koitharu.kotatsu.core.util.ext.putAll import org.koitharu.kotatsu.core.util.ext.putAll
import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.core.util.ext.takeIfReadable import org.koitharu.kotatsu.core.util.ext.takeIfReadable
@@ -82,6 +87,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isNavBarPinned: Boolean val isNavBarPinned: Boolean
get() = prefs.getBoolean(KEY_NAV_PINNED, false) get() = prefs.getBoolean(KEY_NAV_PINNED, false)
val isMainFabEnabled: Boolean
get() = prefs.getBoolean(KEY_MAIN_FAB, true)
var gridSize: Int var gridSize: Int
get() = prefs.getInt(KEY_GRID_SIZE, 100) get() = prefs.getInt(KEY_GRID_SIZE, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) } set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
@@ -494,6 +502,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
) )
} }
var isReaderAutoscrollFabVisible: Boolean
get() = prefs.getBoolean(KEY_READER_AUTOSCROLL_FAB, true)
set(value) = prefs.edit { putBoolean(KEY_READER_AUTOSCROLL_FAB, value) }
val isPagesPreloadEnabled: Boolean val isPagesPreloadEnabled: Boolean
get() { get() {
if (isBackgroundNetworkRestricted()) { if (isBackgroundNetworkRestricted()) {
@@ -509,6 +521,16 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val is32BitColorsEnabled: Boolean val is32BitColorsEnabled: Boolean
get() = prefs.getBoolean(KEY_32BIT_COLOR, false) get() = prefs.getBoolean(KEY_32BIT_COLOR, false)
val isDiscordRpcEnabled: Boolean
get() = prefs.getBoolean(KEY_DISCORD_RPC, false)
val isDiscordRpcSkipNsfw: Boolean
get() = prefs.getBoolean(KEY_DISCORD_RPC_SKIP_NSFW, false)
var discordToken: String?
get() = prefs.getString(KEY_DISCORD_TOKEN, null)?.trim()?.nullIfEmpty()
set(value) = prefs.edit { putString(KEY_DISCORD_TOKEN, value?.nullIfEmpty()) }
val isPeriodicalBackupEnabled: Boolean val isPeriodicalBackupEnabled: Boolean
get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false) get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false)
@@ -598,7 +620,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
prefs.unregisterOnSharedPreferenceChangeListener(listener) prefs.unregisterOnSharedPreferenceChangeListener(listener)
} }
fun observe() = prefs.observe() fun observeChanges() = prefs.observeChanges()
fun observe(vararg keys: String): Flow<String?> = prefs.observeChanges()
.filter { key -> key == null || key in keys }
.onStart { emit(null) }
.flowOn(Dispatchers.IO)
fun getAllValues(): Map<String, *> = prefs.all fun getAllValues(): Map<String, *> = prefs.all
@@ -728,6 +755,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_TIPS_CLOSED = "tips_closed" const val KEY_TIPS_CLOSED = "tips_closed"
const val KEY_SSL_BYPASS = "ssl_bypass" const val KEY_SSL_BYPASS = "ssl_bypass"
const val KEY_READER_AUTOSCROLL_SPEED = "as_speed" const val KEY_READER_AUTOSCROLL_SPEED = "as_speed"
const val KEY_READER_AUTOSCROLL_FAB = "as_fab"
const val KEY_MIRROR_SWITCHING = "mirror_switching" const val KEY_MIRROR_SWITCHING = "mirror_switching"
const val KEY_PROXY = "proxy" const val KEY_PROXY = "proxy"
const val KEY_PROXY_TYPE = "proxy_type_2" const val KEY_PROXY_TYPE = "proxy_type_2"
@@ -743,6 +771,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_NAV_MAIN = "nav_main" const val KEY_NAV_MAIN = "nav_main"
const val KEY_NAV_LABELS = "nav_labels" const val KEY_NAV_LABELS = "nav_labels"
const val KEY_NAV_PINNED = "nav_pinned" const val KEY_NAV_PINNED = "nav_pinned"
const val KEY_MAIN_FAB = "main_fab"
const val KEY_32BIT_COLOR = "enhanced_colors" const val KEY_32BIT_COLOR = "enhanced_colors"
const val KEY_SOURCES_ORDER = "sources_sort_order" const val KEY_SOURCES_ORDER = "sources_sort_order"
const val KEY_SOURCES_CATALOG = "sources_catalog" const val KEY_SOURCES_CATALOG = "sources_catalog"
@@ -768,6 +797,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_BACKUP_TG_CHAT = "backup_periodic_tg_chat_id" const val KEY_BACKUP_TG_CHAT = "backup_periodic_tg_chat_id"
const val KEY_MANGA_LIST_BADGES = "manga_list_badges" const val KEY_MANGA_LIST_BADGES = "manga_list_badges"
const val KEY_TAGS_WARNINGS = "tags_warnings" const val KEY_TAGS_WARNINGS = "tags_warnings"
const val KEY_DISCORD_RPC = "discord_rpc"
const val KEY_DISCORD_RPC_SKIP_NSFW = "discord_rpc_skip_nsfw"
const val KEY_DISCORD_TOKEN = "discord_token"
// keys for non-persistent preferences // keys for non-persistent preferences
const val KEY_APP_VERSION = "app_version" const val KEY_APP_VERSION = "app_version"
@@ -780,6 +812,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PROXY_TEST = "proxy_test" const val KEY_PROXY_TEST = "proxy_test"
const val KEY_OPEN_BROWSER = "open_browser" const val KEY_OPEN_BROWSER = "open_browser"
const val KEY_HANDLE_LINKS = "handle_links" const val KEY_HANDLE_LINKS = "handle_links"
const val KEY_BACKUP_TG = "backup_periodic_tg"
const val KEY_BACKUP_TG_OPEN = "backup_periodic_tg_open" const val KEY_BACKUP_TG_OPEN = "backup_periodic_tg_open"
const val KEY_BACKUP_TG_TEST = "backup_periodic_tg_test" const val KEY_BACKUP_TG_TEST = "backup_periodic_tg_test"
const val KEY_CLEAR_MANGA_DATA = "manga_data_clear" const val KEY_CLEAR_MANGA_DATA = "manga_data_clear"

View File

@@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.transform
fun <T> AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow { fun <T> AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow {
var lastValue: T = valueProducer() var lastValue: T = valueProducer()
emit(lastValue) emit(lastValue)
observe().collect { observeChanges().collect {
if (it == key) { if (it == key) {
val value = valueProducer() val value = valueProducer()
if (value != lastValue) { if (value != lastValue) {
@@ -25,7 +25,7 @@ fun <T> AppSettings.observeAsStateFlow(
scope: CoroutineScope, scope: CoroutineScope,
key: String, key: String,
valueProducer: AppSettings.() -> T, valueProducer: AppSettings.() -> T,
): StateFlow<T> = observe().transform { ): StateFlow<T> = observeChanges().transform {
if (it == key) { if (it == key) {
emit(valueProducer()) emit(valueProducer())
} }

View File

@@ -66,8 +66,9 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
companion object { companion object {
const val KEY_SORT_ORDER = "sort_order" const val KEY_DOMAIN = "domain"
const val KEY_SLOWDOWN = "slowdown"
const val KEY_NO_CAPTCHA = "no_captcha" const val KEY_NO_CAPTCHA = "no_captcha"
const val KEY_SLOWDOWN = "slowdown"
const val KEY_SORT_ORDER = "sort_order"
} }
} }

View File

@@ -1,9 +1,11 @@
package org.koitharu.kotatsu.core.ui package org.koitharu.kotatsu.core.ui
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
@@ -86,6 +88,12 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
(activity as? SettingsActivity)?.setSectionTitle(title) (activity as? SettingsActivity)?.setSectionTitle(title)
} }
protected fun getWarningIcon(): Drawable? = context?.let { ctx ->
ContextCompat.getDrawable(ctx, R.drawable.ic_alert_outline)?.also {
it.setTint(ContextCompat.getColor(ctx, R.color.warning))
}
}
private fun focusPreference(key: String) { private fun focusPreference(key: String) {
val pref = findPreference<Preference>(key) val pref = findPreference<Preference>(key)
if (pref == null) { if (pref == null) {

View File

@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.util.ext.EventFlow
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
@@ -43,13 +44,13 @@ abstract class BaseViewModel : ViewModel() {
context: CoroutineContext = EmptyCoroutineContext, context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT, start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit block: suspend CoroutineScope.() -> Unit
): Job = viewModelScope.launch(context + createErrorHandler(), start, block) ): Job = viewModelScope.launch(context.withDefaultExceptionHandler(), start, block)
protected fun launchLoadingJob( protected fun launchLoadingJob(
context: CoroutineContext = EmptyCoroutineContext, context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT, start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit block: suspend CoroutineScope.() -> Unit
): Job = viewModelScope.launch(context + createErrorHandler(), start) { ): Job = viewModelScope.launch(context.withDefaultExceptionHandler(), start) {
loadingCounter.increment() loadingCounter.increment()
try { try {
block() block()
@@ -80,10 +81,28 @@ abstract class BaseViewModel : ViewModel() {
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 } protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable -> private fun CoroutineContext.withDefaultExceptionHandler() =
throwable.printStackTraceDebug() if (this[CoroutineExceptionHandler.Key] is EventExceptionHandler) {
if (throwable !is CancellationException) { this
errorEvent.call(throwable) } else {
this + EventExceptionHandler(errorEvent)
}
protected object SkipErrors : AbstractCoroutineContextElement(Key) {
private object Key : CoroutineContext.Key<SkipErrors>
}
protected class EventExceptionHandler(
private val event: MutableEventFlow<Throwable>,
) : AbstractCoroutineContextElement(CoroutineExceptionHandler),
CoroutineExceptionHandler {
override fun handleException(context: CoroutineContext, exception: Throwable) {
exception.printStackTraceDebug()
if (context[SkipErrors.key] == null && exception !is CancellationException) {
event.call(exception)
}
} }
} }
} }

View File

@@ -10,7 +10,7 @@ class RememberCheckListener(
var isChecked: Boolean = initialValue var isChecked: Boolean = initialValue
private set private set
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) { override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
this.isChecked = isChecked this.isChecked = isChecked
} }
} }

View File

@@ -1,61 +1,38 @@
package org.koitharu.kotatsu.core.util package org.koitharu.kotatsu.core.util
import androidx.collection.ArrayMap import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import java.util.concurrent.ConcurrentHashMap
import kotlin.contracts.InvocationKind import kotlin.contracts.InvocationKind
import kotlin.contracts.contract import kotlin.contracts.contract
open class MultiMutex<T : Any> : Set<T> { open class MultiMutex<T : Any> {
private val delegates = ArrayMap<T, Mutex>() private val delegates = ConcurrentHashMap<T, Mutex>()
override val size: Int @VisibleForTesting
get() = delegates.size val size: Int
get() = delegates.count { it.value.isLocked }
override fun contains(element: T): Boolean = synchronized(delegates) { fun isNotEmpty() = delegates.any { it.value.isLocked }
delegates.containsKey(element)
}
override fun containsAll(elements: Collection<T>): Boolean = synchronized(delegates) { fun isEmpty() = delegates.none { it.value.isLocked }
elements.all { x -> delegates.containsKey(x) }
}
override fun isEmpty(): Boolean = delegates.isEmpty()
override fun iterator(): Iterator<T> = synchronized(delegates) {
delegates.keys.toList()
}.iterator()
fun isLocked(element: T): Boolean = synchronized(delegates) {
delegates[element]?.isLocked == true
}
fun tryLock(element: T): Boolean {
val mutex = synchronized(delegates) {
delegates.getOrPut(element, ::Mutex)
}
return mutex.tryLock()
}
suspend fun lock(element: T) { suspend fun lock(element: T) {
val mutex = synchronized(delegates) { val mutex = delegates.computeIfAbsent(element) { Mutex() }
delegates.getOrPut(element, ::Mutex)
}
mutex.lock() mutex.lock()
} }
fun unlock(element: T) { fun unlock(element: T) {
synchronized(delegates) { delegates[element]?.unlock()
delegates.remove(element)?.unlock()
}
} }
suspend inline fun <R> withLock(element: T, block: () -> R): R { suspend inline fun <R> withLock(element: T, block: () -> R): R {
contract { contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE) callsInPlace(block, InvocationKind.EXACTLY_ONCE)
} }
lock(element)
return try { return try {
lock(element)
block() block()
} finally { } finally {
unlock(element) unlock(element)

View File

@@ -134,6 +134,28 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
) )
} }
@Suppress("UNCHECKED_CAST")
fun <T1, T2, T3, T4, T5, T6, T7, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
flow7: Flow<T7>,
transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R,
): Flow<R> = combine(flow, flow2, flow3, flow4, flow5, flow6, flow7) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
args[3] as T4,
args[4] as T5,
args[5] as T6,
args[6] as T7,
)
}
suspend fun <T : Any> Flow<T?>.firstNotNull(): T = checkNotNull(first { x -> x != null }) suspend fun <T : Any> Flow<T?>.firstNotNull(): T = checkNotNull(first { x -> x != null })
suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x != null } suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x != null }

View File

@@ -1,5 +1,8 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import android.content.ContentResolver
import android.net.Uri
import androidx.annotation.CheckResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
@@ -12,6 +15,7 @@ import okio.FileSystem
import okio.IOException import okio.IOException
import okio.Path import okio.Path
import okio.Source import okio.Source
import okio.source
import org.koitharu.kotatsu.core.util.CancellableSource import org.koitharu.kotatsu.core.util.CancellableSource
import org.koitharu.kotatsu.core.util.progress.ProgressResponseBody import org.koitharu.kotatsu.core.util.progress.ProgressResponseBody
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
@@ -57,3 +61,8 @@ fun FileSystem.isRegularFile(path: Path) = try {
} catch (_: IOException) { } catch (_: IOException) {
false false
} }
@CheckResult
fun ContentResolver.openSource(uri: Uri): Source = checkNotNull(openInputStream(uri)) {
"Cannot open input stream from $uri"
}.source()

View File

@@ -37,7 +37,7 @@ fun <E : Enum<E>> SharedPreferences.Editor.putEnumValue(key: String, value: E?)
putString(key, value?.name) putString(key, value?.name)
} }
fun SharedPreferences.observe(): Flow<String?> = callbackFlow { fun SharedPreferences.observeChanges(): Flow<String?> = callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
trySendBlocking(key) trySendBlocking(key)
} }
@@ -49,7 +49,7 @@ fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
fun <T> SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow<T> = flow { fun <T> SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow<T> = flow {
emit(valueProducer()) emit(valueProducer())
observe().collect { upstreamKey -> observeChanges().collect { upstreamKey ->
if (upstreamKey == key) { if (upstreamKey == key) {
emit(valueProducer()) emit(valueProducer())
} }

View File

@@ -6,6 +6,7 @@ import android.database.sqlite.SQLiteFullException
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import coil3.network.HttpException import coil3.network.HttpException
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import kotlinx.coroutines.CancellationException
import okhttp3.Response import okhttp3.Response
import okhttp3.internal.http2.StreamResetException import okhttp3.internal.http2.StreamResetException
import okio.FileNotFoundException import okio.FileNotFoundException
@@ -52,6 +53,7 @@ import java.net.SocketException
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.net.UnknownHostException import java.net.UnknownHostException
import java.util.Locale import java.util.Locale
import java.util.zip.ZipException
private const val MSG_NO_SPACE_LEFT = "No space left on device" private const val MSG_NO_SPACE_LEFT = "No space left on device"
private const val MSG_CONNECTION_RESET = "Connection reset" private const val MSG_CONNECTION_RESET = "Connection reset"
@@ -63,6 +65,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessag
?: resources.getString(R.string.error_occurred) ?: resources.getString(R.string.error_occurred)
private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) { private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) {
is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message
is CaughtException -> cause.getDisplayMessageOrNull(resources) is CaughtException -> cause.getDisplayMessageOrNull(resources)
is WrapperIOException -> cause.getDisplayMessageOrNull(resources) is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
is ScrobblerAuthRequiredException -> resources.getString( is ScrobblerAuthRequiredException -> resources.getString(
@@ -92,6 +95,7 @@ private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = w
} }
} }
is ZipException -> resources.getString(R.string.error_corrupted_zip, this.message.orEmpty())
is SQLiteFullException -> resources.getString(R.string.error_no_space_left) is SQLiteFullException -> resources.getString(R.string.error_no_space_left)
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported) is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message) is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
@@ -168,8 +172,9 @@ fun Throwable.getCauseUrl(): String? = when (this) {
} }
private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) { private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) {
404 -> resources.getString(R.string.not_found_404) HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404)
403 -> resources.getString(R.string.access_denied_403) HttpURLConnection.HTTP_FORBIDDEN -> resources.getString(R.string.access_denied_403)
HttpURLConnection.HTTP_GATEWAY_TIMEOUT -> resources.getString(R.string.network_unavailable)
in 500..599 -> resources.getString(R.string.server_error, statusCode) in 500..599 -> resources.getString(R.string.server_error, statusCode)
else -> null else -> null
} }

View File

@@ -175,6 +175,16 @@ fun View.setOnContextClickListenerCompat(listener: OnContextClickListenerCompat)
} }
} }
fun View.setTooltipCompat(tooltip: CharSequence?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
tooltipText = tooltip
} else if (!isLongClickable) { // don't use TooltipCompat if has a LongClickListener
TooltipCompat.setTooltipText(this, tooltip)
}
}
fun View.setTooltipCompat(@StringRes tooltipResId: Int) = setTooltipCompat(context.getString(tooltipResId))
val Toolbar.menuView: ActionMenuView? val Toolbar.menuView: ActionMenuView?
get() { get() {
menu // to call ensureMenu() menu // to call ensureMenu()
@@ -201,7 +211,7 @@ fun Chip.setProgressIcon() {
fun View.setContentDescriptionAndTooltip(@StringRes resId: Int) { fun View.setContentDescriptionAndTooltip(@StringRes resId: Int) {
val text = resources.getString(resId) val text = resources.getString(resId)
contentDescription = text contentDescription = text
TooltipCompat.setTooltipText(this, text) setTooltipCompat(text)
} }
fun View.getWindowBounds(): Rect { fun View.getWindowBounds(): Rect {

View File

@@ -31,13 +31,12 @@ data class MangaDetails(
val id: Long val id: Long
get() = manga.id get() = manga.id
val chapters: Map<String?, List<MangaChapter>> = manga.chapters?.groupBy { it.branch }.orEmpty()
val branches: Set<String?>
get() = chapters.keys
val allChapters: List<MangaChapter> by lazy { mergeChapters() } val allChapters: List<MangaChapter> by lazy { mergeChapters() }
val chapters: Map<String?, List<MangaChapter>> by lazy {
allChapters.groupBy { it.branch }
}
val isLocal val isLocal
get() = manga.isLocal get() = manga.isLocal
@@ -51,7 +50,22 @@ data class MangaDetails(
.ifNullOrEmpty { localManga?.manga?.coverUrl } .ifNullOrEmpty { localManga?.manga?.coverUrl }
?.nullIfEmpty() ?.nullIfEmpty()
fun toManga() = manga.withOverride(override) private val mergedManga by lazy {
if (localManga == null) {
// fast path
manga.withOverride(override)
} else {
manga.copy(
title = override?.title.ifNullOrEmpty { manga.title },
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
largeCoverUrl = override?.coverUrl.ifNullOrEmpty { manga.largeCoverUrl },
contentRating = override?.contentRating ?: manga.contentRating,
chapters = allChapters,
)
}
}
fun toManga() = mergedManga
fun getLocale(): Locale? { fun getLocale(): Locale? {
findAppropriateLocale(chapters.keys.singleOrNull())?.let { findAppropriateLocale(chapters.keys.singleOrNull())?.let {

View File

@@ -1,11 +1,13 @@
package org.koitharu.kotatsu.details.domain package org.koitharu.kotatsu.details.domain
import android.util.Log
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings

View File

@@ -9,30 +9,31 @@ import androidx.core.text.parseAsHtml
import coil3.request.CachePolicy import coil3.request.CachePolicy
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.launch import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okio.IOException
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.nav.MangaIntent import org.koitharu.kotatsu.core.nav.MangaIntent
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.CachingMangaRepository import org.koitharu.kotatsu.core.parser.CachingMangaRepository
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.peek import org.koitharu.kotatsu.core.ui.model.MangaOverride
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.explore.domain.RecoverMangaUseCase import org.koitharu.kotatsu.explore.domain.RecoverMangaUseCase
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.parsers.util.recoverNotNull import org.koitharu.kotatsu.parsers.util.recoverNotNull
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.tracker.domain.CheckNewChaptersUseCase
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider
class DetailsLoadUseCase @Inject constructor( class DetailsLoadUseCase @Inject constructor(
private val mangaDataRepository: MangaDataRepository, private val mangaDataRepository: MangaDataRepository,
@@ -40,84 +41,116 @@ class DetailsLoadUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val recoverUseCase: RecoverMangaUseCase, private val recoverUseCase: RecoverMangaUseCase,
private val imageGetter: Html.ImageGetter, private val imageGetter: Html.ImageGetter,
private val newChaptersUseCaseProvider: Provider<CheckNewChaptersUseCase>,
private val networkState: NetworkState, private val networkState: NetworkState,
) { ) {
operator fun invoke(intent: MangaIntent, force: Boolean): Flow<MangaDetails> = channelFlow { operator fun invoke(intent: MangaIntent, force: Boolean): Flow<MangaDetails> = flow {
val manga = requireNotNull(mangaDataRepository.resolveIntent(intent, withChapters = true)) { val manga = requireNotNull(mangaDataRepository.resolveIntent(intent, withChapters = true)) {
"Cannot resolve intent $intent" "Cannot resolve intent $intent"
} }
val override = mangaDataRepository.getOverride(manga.id) val override = mangaDataRepository.getOverride(manga.id)
send( emit(
MangaDetails( MangaDetails(
manga = manga, manga = manga,
localManga = null, localManga = null,
override = override, override = override,
description = null, description = manga.description?.parseAsHtml(withImages = false),
isLoaded = false, isLoaded = false,
), ),
) )
val local = if (!manga.isLocal) { if (manga.isLocal) {
async { loadLocal(manga, override, force)
localMangaRepository.findSavedManga(manga)
}
} else { } else {
null loadRemote(manga, override, force)
} }
if (!force && networkState.isOfflineOrRestricted()) { }.distinctUntilChanged()
// try to avoid loading if has saved manga .flowOn(Dispatchers.Default)
val localManga = local?.await()
if (manga.isLocal || localManga != null) { /**
send( * Load local manga + try to load the linked remote one if network is not restricted
MangaDetails( * Suppress any network errors
manga = manga, */
localManga = localManga, private suspend fun FlowCollector<MangaDetails>.loadLocal(manga: Manga, override: MangaOverride?, force: Boolean) {
override = override, val skipNetworkLoad = !force && networkState.isOfflineOrRestricted()
description = manga.description?.parseAsHtml(withImages = true)?.trim(), val localDetails = localMangaRepository.getDetails(manga)
isLoaded = true, emit(
), MangaDetails(
) manga = localDetails,
return@channelFlow localManga = null,
} override = override,
description = localDetails.description?.parseAsHtml(withImages = false),
isLoaded = skipNetworkLoad,
),
)
if (skipNetworkLoad) {
return
} }
try { val remoteManga = localMangaRepository.getRemoteManga(manga)
val details = getDetails(manga, force) if (remoteManga == null) {
launch { mangaDataRepository.updateChapters(details) } emit(
launch { updateTracker(details) }
send(
MangaDetails( MangaDetails(
manga = details, manga = localDetails,
localManga = local?.peek(), localManga = null,
override = override, override = override,
description = details.description?.parseAsHtml(withImages = false)?.trim(), description = localDetails.description?.parseAsHtml(withImages = true),
isLoaded = false,
),
)
send(
MangaDetails(
manga = details,
localManga = local?.await(),
override = override,
description = details.description?.parseAsHtml(withImages = true)?.trim(),
isLoaded = true, isLoaded = true,
), ),
) )
} catch (e: IOException) { } else {
local?.await()?.manga?.also { localManga -> val remoteDetails = getDetails(remoteManga, force).getOrNull()
send( emit(
MangaDetails( MangaDetails(
manga = localManga, manga = remoteDetails ?: remoteManga,
localManga = null, localManga = LocalManga(localDetails),
override = override, override = override,
description = localManga.description?.parseAsHtml(withImages = false)?.trim(), description = (remoteDetails ?: localDetails).description?.parseAsHtml(withImages = true),
isLoaded = true, isLoaded = true,
), ),
) )
} ?: close(e) if (remoteDetails != null) {
mangaDataRepository.updateChapters(remoteDetails)
}
} }
} }
/**
* Load remote manga + saved one if available
* Throw network errors after loading local manga only
*/
private suspend fun FlowCollector<MangaDetails>.loadRemote(
manga: Manga,
override: MangaOverride?,
force: Boolean
) = coroutineScope {
val remoteDeferred = async {
getDetails(manga, force)
}
val localManga = localMangaRepository.findSavedManga(manga, withDetails = true)
if (localManga != null) {
emit(
MangaDetails(
manga = manga,
localManga = localManga,
override = override,
description = localManga.manga.description?.parseAsHtml(withImages = true),
isLoaded = false,
),
)
}
val remoteDetails = remoteDeferred.await().getOrThrow()
emit(
MangaDetails(
manga = remoteDetails,
localManga = localManga,
override = override,
description = (remoteDetails.description
?: localManga?.manga?.description)?.parseAsHtml(withImages = true),
isLoaded = true,
),
)
mangaDataRepository.updateChapters(remoteDetails)
}
private suspend fun getDetails(seed: Manga, force: Boolean) = runCatchingCancellable { private suspend fun getDetails(seed: Manga, force: Boolean) = runCatchingCancellable {
val repository = mangaRepositoryFactory.create(seed.source) val repository = mangaRepositoryFactory.create(seed.source)
if (repository is CachingMangaRepository) { if (repository is CachingMangaRepository) {
@@ -131,20 +164,18 @@ class DetailsLoadUseCase @Inject constructor(
} else { } else {
null null
} }
}.getOrThrow()
private suspend fun String.parseAsHtml(withImages: Boolean): CharSequence? {
return if (withImages) {
runInterruptible(Dispatchers.IO) {
parseAsHtml(imageGetter = imageGetter)
}.filterSpans()
} else {
runInterruptible(Dispatchers.Default) {
parseAsHtml()
}.filterSpans().sanitize()
}.takeUnless { it.isBlank() }
} }
private suspend fun String.parseAsHtml(withImages: Boolean): CharSequence? = if (withImages) {
runInterruptible(Dispatchers.IO) {
parseAsHtml(imageGetter = imageGetter)
}.filterSpans()
} else {
runInterruptible(Dispatchers.Default) {
parseAsHtml()
}.filterSpans().sanitize()
}.trim().nullIfEmpty()
private fun Spanned.filterSpans(): Spanned { private fun Spanned.filterSpans(): Spanned {
val spannable = SpannableString.valueOf(this) val spannable = SpannableString.valueOf(this)
val spans = spannable.getSpans<ForegroundColorSpan>() val spans = spannable.getSpans<ForegroundColorSpan>()
@@ -153,10 +184,4 @@ class DetailsLoadUseCase @Inject constructor(
} }
return spannable return spannable
} }
private suspend fun updateTracker(details: Manga) = runCatchingCancellable {
newChaptersUseCaseProvider.get()(details)
}.onFailure { e ->
e.printStackTraceDebug()
}
} }

View File

@@ -16,6 +16,7 @@ fun MangaDetails.mapChapters(
branch: String?, branch: String?,
bookmarks: List<Bookmark>, bookmarks: List<Bookmark>,
isGrid: Boolean, isGrid: Boolean,
isDownloadedOnly: Boolean,
): List<ChapterListItem> { ): List<ChapterListItem> {
val remoteChapters = chapters[branch].orEmpty() val remoteChapters = chapters[branch].orEmpty()
val localChapters = local?.manga?.getChapters(branch).orEmpty() val localChapters = local?.manga?.getChapters(branch).orEmpty()
@@ -35,19 +36,21 @@ fun MangaDetails.mapChapters(
null null
} }
var isUnread = currentChapterId !in ids var isUnread = currentChapterId !in ids
for (chapter in remoteChapters) { if (!isDownloadedOnly || local?.manga?.chapters == null) {
val local = localMap?.remove(chapter.id) for (chapter in remoteChapters) {
if (chapter.id == currentChapterId) { val local = localMap?.remove(chapter.id)
isUnread = true if (chapter.id == currentChapterId) {
isUnread = true
}
result += (local ?: chapter).toListItem(
isCurrent = chapter.id == currentChapterId,
isUnread = isUnread,
isNew = isUnread && result.size >= newFrom,
isDownloaded = local != null,
isBookmarked = chapter.id in bookmarked,
isGrid = isGrid,
)
} }
result += (local ?: chapter).toListItem(
isCurrent = chapter.id == currentChapterId,
isUnread = isUnread,
isNew = isUnread && result.size >= newFrom,
isDownloaded = local != null,
isBookmarked = chapter.id in bookmarked,
isGrid = isGrid,
)
} }
if (!localMap.isNullOrEmpty()) { if (!localMap.isNullOrEmpty()) {
for (chapter in localMap.values) { for (chapter in localMap.values) {

View File

@@ -11,7 +11,6 @@ import android.view.ViewGroup
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
import android.widget.Toast import android.widget.Toast
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.widget.TooltipCompat
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans import androidx.core.text.inSpans
import androidx.core.text.method.LinkMovementMethodCompat import androidx.core.text.method.LinkMovementMethodCompat
@@ -80,6 +79,7 @@ import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.parentView import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.setTooltipCompat
import org.koitharu.kotatsu.core.util.ext.start import org.koitharu.kotatsu.core.util.ext.start
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.core.util.ext.toUriOrNull
@@ -260,7 +260,7 @@ class DetailsActivity :
R.id.button_scrobbling_more -> { R.id.button_scrobbling_more -> {
router.showScrobblingSelectorSheet( router.showScrobblingSelectorSheet(
manga = viewModel.getMangaOrNull() ?: return, manga = viewModel.getMangaOrNull() ?: return,
scrobblerService = viewModel.scrobblingInfo.value.firstOrNull()?.scrobbler scrobblerService = viewModel.scrobblingInfo.value.firstOrNull()?.scrobbler,
) )
} }
@@ -389,7 +389,7 @@ class DetailsActivity :
mangaGridItemAD( mangaGridItemAD(
sizeResolver = StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)), sizeResolver = StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)),
) { item, view -> ) { item, view ->
router.openDetails(item) router.openDetails(item.toMangaWithOverride())
}, },
).also { rv.adapter = it } ).also { rv.adapter = it }
adapter.items = related adapter.items = related
@@ -455,7 +455,7 @@ class DetailsActivity :
textViewSourceLabel.isVisible = false textViewSourceLabel.isVisible = false
} else { } else {
textViewSource.textAndVisible = manga.source.getTitle(this@DetailsActivity) textViewSource.textAndVisible = manga.source.getTitle(this@DetailsActivity)
TooltipCompat.setTooltipText(textViewSource, manga.source.getSummary(this@DetailsActivity)) textViewSource.setTooltipCompat(manga.source.getSummary(this@DetailsActivity))
textViewSourceLabel.isVisible = textViewSource.isVisible == true textViewSourceLabel.isVisible = textViewSource.isVisible == true
} }
val faviconPlaceholderFactory = FaviconDrawable.Factory(R.style.FaviconDrawable_Chip) val faviconPlaceholderFactory = FaviconDrawable.Factory(R.style.FaviconDrawable_Chip)

View File

@@ -182,7 +182,7 @@ class DetailsViewModel @Inject constructor(
init { init {
loadingJob = doLoad(force = false) loadingJob = doLoad(force = false)
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default + SkipErrors) {
val manga = mangaDetails.firstOrNull { !it?.chapters.isNullOrEmpty() } ?: return@launchJob val manga = mangaDetails.firstOrNull { !it?.chapters.isNullOrEmpty() } ?: return@launchJob
val h = history.firstOrNull() val h = history.firstOrNull()
if (h != null) { if (h != null) {

View File

@@ -106,7 +106,7 @@ class ReadButtonDelegate(
} }
private fun openReader(isIncognitoMode: Boolean) { private fun openReader(isIncognitoMode: Boolean) {
val manga = viewModel.manga.value ?: return val manga = viewModel.getMangaOrNull() ?: return
if (viewModel.historyInfo.value.isChapterMissing) { if (viewModel.historyInfo.value.isChapterMissing) {
Snackbar.make(buttonRead, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT) Snackbar.make(buttonRead, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT)
.show() // TODO .show() // TODO

View File

@@ -1,12 +1,12 @@
package org.koitharu.kotatsu.details.ui.adapter package org.koitharu.kotatsu.details.ui.adapter
import android.graphics.Typeface import android.graphics.Typeface
import androidx.appcompat.widget.TooltipCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
import org.koitharu.kotatsu.core.util.ext.setTooltipCompat
import org.koitharu.kotatsu.databinding.ItemChapterGridBinding import org.koitharu.kotatsu.databinding.ItemChapterGridBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -23,7 +23,7 @@ fun chapterGridItemAD(
bind { payloads -> bind { payloads ->
if (payloads.isEmpty()) { if (payloads.isEmpty()) {
binding.textViewTitle.text = item.chapter.numberString() ?: "?" binding.textViewTitle.text = item.chapter.numberString() ?: "?"
TooltipCompat.setTooltipText(itemView, item.chapter.title) itemView.setTooltipCompat(item.chapter.title)
} }
binding.imageViewNew.isVisible = item.isNew binding.imageViewNew.isVisible = item.isNew
binding.imageViewCurrent.isVisible = item.isCurrent binding.imageViewCurrent.isVisible = item.isCurrent

View File

@@ -9,6 +9,7 @@ import androidx.core.view.MenuProvider
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.slider.LabelFormatter import com.google.android.material.slider.LabelFormatter
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import com.google.android.material.slider.TickVisibilityMode
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
@@ -38,9 +39,13 @@ class ChapterPagesMenuProvider(
setOnActionExpandListener(this@ChapterPagesMenuProvider) setOnActionExpandListener(this@ChapterPagesMenuProvider)
(actionView as? SearchView)?.setupChaptersSearchView() (actionView as? SearchView)?.setupChaptersSearchView()
} }
menu.findItem(R.id.action_search)?.isVisible = viewModel.isChaptersEmpty.value == false menu.findItem(R.id.action_search)?.isVisible = viewModel.emptyReason.value == null
menu.findItem(R.id.action_reversed)?.isChecked = viewModel.isChaptersReversed.value == true menu.findItem(R.id.action_reversed)?.isChecked = viewModel.isChaptersReversed.value == true
menu.findItem(R.id.action_grid_view)?.isChecked = viewModel.isChaptersInGridView.value == true menu.findItem(R.id.action_grid_view)?.isChecked = viewModel.isChaptersInGridView.value == true
menu.findItem(R.id.action_downloaded)?.let { menuItem ->
menuItem.isVisible = viewModel.mangaDetails.value?.local != null
menuItem.isChecked = viewModel.isDownloadedOnly.value == true
}
} }
TAB_PAGES, TAB_BOOKMARKS -> { TAB_PAGES, TAB_BOOKMARKS -> {
@@ -64,6 +69,11 @@ class ChapterPagesMenuProvider(
true true
} }
R.id.action_downloaded -> {
viewModel.isDownloadedOnly.value = !menuItem.isChecked
true
}
else -> false else -> false
} }
@@ -110,7 +120,7 @@ class ChapterPagesMenuProvider(
valueFrom = 50f valueFrom = 50f
valueTo = 150f valueTo = 150f
stepSize = 5f stepSize = 5f
isTickVisible = false tickVisibilityMode = TickVisibilityMode.TICK_VISIBILITY_HIDDEN
labelBehavior = LabelFormatter.LABEL_FLOATING labelBehavior = LabelFormatter.LABEL_FLOATING
setLabelFormatter(IntPercentLabelFormatter(context)) setLabelFormatter(IntPercentLabelFormatter(context))
setValueRounded(settings.gridSizePages.toFloat()) setValueRounded(settings.gridSizePages.toFloat())

View File

@@ -81,6 +81,7 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(),
val menuInvalidator = MenuInvalidator(binding.toolbar) val menuInvalidator = MenuInvalidator(binding.toolbar)
viewModel.isChaptersReversed.observe(viewLifecycleOwner, menuInvalidator) viewModel.isChaptersReversed.observe(viewLifecycleOwner, menuInvalidator)
viewModel.isChaptersInGridView.observe(viewLifecycleOwner, menuInvalidator) viewModel.isChaptersInGridView.observe(viewLifecycleOwner, menuInvalidator)
viewModel.isDownloadedOnly.observe(viewLifecycleOwner, menuInvalidator)
actionModeDelegate?.addListener(this, viewLifecycleOwner) actionModeDelegate?.addListener(this, viewLifecycleOwner)
addSheetCallback(this, viewLifecycleOwner) addSheetCallback(this, viewLifecycleOwner)

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
@@ -43,6 +44,7 @@ import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.ReaderViewModel import org.koitharu.kotatsu.reader.ui.ReaderViewModel
@@ -87,6 +89,8 @@ abstract class ChaptersPagesViewModel(
valueProducer = { isChaptersGridView }, valueProducer = { isChaptersGridView },
) )
val isDownloadedOnly = MutableStateFlow(false)
val newChaptersCount = mangaDetails.flatMapLatest { d -> val newChaptersCount = mangaDetails.flatMapLatest { d ->
if (d?.isLocal == false) { if (d?.isLocal == false) {
interactor.observeNewChapters(d.id) interactor.observeNewChapters(d.id)
@@ -95,9 +99,19 @@ abstract class ChaptersPagesViewModel(
} }
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
val isChaptersEmpty: StateFlow<Boolean> = mangaDetails.map { val emptyReason: StateFlow<EmptyMangaReason?> = combine(
it != null && it.isLoaded && it.allChapters.isEmpty() mangaDetails,
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) isLoading,
onError.onStart { emit(null) },
) { details, loading, error ->
when {
details == null || loading -> null
details.chapters.isNotEmpty() -> null
details.toManga().state == MangaState.RESTRICTED -> EmptyMangaReason.RESTRICTED
error != null -> EmptyMangaReason.LOADING_ERROR
else -> EmptyMangaReason.NO_CHAPTERS
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(), null)
val bookmarks = mangaDetails.flatMapLatest { val bookmarks = mangaDetails.flatMapLatest {
if (it != null) { if (it != null) {
@@ -115,13 +129,15 @@ abstract class ChaptersPagesViewModel(
newChaptersCount, newChaptersCount,
bookmarks, bookmarks,
isChaptersInGridView, isChaptersInGridView,
) { manga, currentChapterId, branch, news, bookmarks, grid -> isDownloadedOnly,
) { manga, currentChapterId, branch, news, bookmarks, grid, downloadedOnly ->
manga?.mapChapters( manga?.mapChapters(
currentChapterId, currentChapterId = currentChapterId,
news, newCount = news,
branch, branch = branch,
bookmarks, bookmarks = bookmarks,
grid, isGrid = grid,
isDownloadedOnly = downloadedOnly,
).orEmpty() ).orEmpty()
}, },
isChaptersReversed, isChaptersReversed,

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.details.ui.pager
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
enum class EmptyMangaReason(
@StringRes val msgResId: Int,
) {
NO_CHAPTERS(R.string.no_chapters_in_manga),
LOADING_ERROR(R.string.chapters_load_failed),
RESTRICTED(R.string.manga_restricted_description),
}

View File

@@ -30,6 +30,7 @@ import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.findParentCallback import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
@@ -96,8 +97,8 @@ class ChaptersFragment :
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.observe(viewLifecycleOwner, this::onChaptersChanged) .observe(viewLifecycleOwner, this::onChaptersChanged)
viewModel.quickFilter.observe(viewLifecycleOwner, this::onFilterChanged) viewModel.quickFilter.observe(viewLifecycleOwner, this::onFilterChanged)
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) { viewModel.emptyReason.observe(viewLifecycleOwner) {
binding.textViewHolder.isVisible = it binding.textViewHolder.setTextAndVisible(it?.msgResId ?: 0)
} }
} }

View File

@@ -24,7 +24,8 @@ import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.ext.fetch import org.koitharu.kotatsu.core.util.ext.fetch
import org.koitharu.kotatsu.core.util.ext.isNetworkUri import org.koitharu.kotatsu.core.util.ext.isNetworkUri
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.LocalStorageCache
import org.koitharu.kotatsu.local.data.PageCache
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.mimeType import org.koitharu.kotatsu.parsers.util.mimeType
import org.koitharu.kotatsu.parsers.util.requireBody import org.koitharu.kotatsu.parsers.util.requireBody
@@ -34,7 +35,7 @@ import javax.inject.Inject
class MangaPageFetcher( class MangaPageFetcher(
private val okHttpClient: OkHttpClient, private val okHttpClient: OkHttpClient,
private val pagesCache: PagesCache, private val pagesCache: LocalStorageCache,
private val options: Options, private val options: Options,
private val page: MangaPage, private val page: MangaPage,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
@@ -53,7 +54,7 @@ class MangaPageFetcher(
val repo = mangaRepositoryFactory.create(page.source) val repo = mangaRepositoryFactory.create(page.source)
val pageUrl = repo.getPageUrl(page) val pageUrl = repo.getPageUrl(page)
if (options.diskCachePolicy.readEnabled) { if (options.diskCachePolicy.readEnabled) {
pagesCache.get(pageUrl)?.let { file -> pagesCache[pageUrl]?.let { file ->
return SourceFetchResult( return SourceFetchResult(
source = ImageSource(file.toOkioPath(), options.fileSystem), source = ImageSource(file.toOkioPath(), options.fileSystem),
mimeType = MimeTypes.getMimeTypeFromExtension(file.name)?.toString(), mimeType = MimeTypes.getMimeTypeFromExtension(file.name)?.toString(),
@@ -78,7 +79,7 @@ class MangaPageFetcher(
} }
val mimeType = response.mimeType?.toMimeTypeOrNull() val mimeType = response.mimeType?.toMimeTypeOrNull()
val file = response.requireBody().use { val file = response.requireBody().use {
pagesCache.put(pageUrl, it.source(), mimeType) pagesCache.set(pageUrl, it.source(), mimeType)
} }
SourceFetchResult( SourceFetchResult(
source = ImageSource(file.toOkioPath(), FileSystem.SYSTEM), source = ImageSource(file.toOkioPath(), FileSystem.SYSTEM),
@@ -107,7 +108,7 @@ class MangaPageFetcher(
class Factory @Inject constructor( class Factory @Inject constructor(
@MangaHttpClient private val okHttpClient: OkHttpClient, @MangaHttpClient private val okHttpClient: OkHttpClient,
private val pagesCache: PagesCache, @PageCache private val pagesCache: LocalStorageCache,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val imageProxyInterceptor: ImageProxyInterceptor, private val imageProxyInterceptor: ImageProxyInterceptor,
) : Fetcher.Factory<MangaPage> { ) : Fetcher.Factory<MangaPage> {

View File

@@ -11,7 +11,6 @@ import androidx.appcompat.view.ActionMode
import androidx.collection.ArraySet import androidx.collection.ArraySet
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@@ -37,9 +36,11 @@ import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.findParentCallback import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.core.util.ext.showOrHide import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.databinding.FragmentPagesBinding import org.koitharu.kotatsu.databinding.FragmentPagesBinding
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
import org.koitharu.kotatsu.list.ui.GridSpanResolver import org.koitharu.kotatsu.list.ui.GridSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
@@ -125,11 +126,18 @@ class PagesFragment :
it.spanCount = checkNotNull(spanResolver).spanCount it.spanCount = checkNotNull(spanResolver).spanCount
} }
} }
parentViewModel.isChaptersEmpty.observe(viewLifecycleOwner, ::onNoChaptersChanged) parentViewModel.emptyReason.observe(viewLifecycleOwner, ::onNoChaptersChanged)
viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged) viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged)
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(binding.recyclerView)) viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(binding.recyclerView))
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) } combine(
viewModel.isLoading,
viewModel.thumbnails,
) { loading, content ->
loading && content.isEmpty()
}.observe(viewLifecycleOwner) {
binding.progressBar.showOrHide(it)
}
viewModel.isLoadingUp.observe(viewLifecycleOwner) { binding.progressBarTop.showOrHide(it) } viewModel.isLoadingUp.observe(viewLifecycleOwner) { binding.progressBarTop.showOrHide(it) }
viewModel.isLoadingDown.observe(viewLifecycleOwner) { binding.progressBarBottom.showOrHide(it) } viewModel.isLoadingDown.observe(viewLifecycleOwner) { binding.progressBarBottom.showOrHide(it) }
} }
@@ -237,10 +245,10 @@ class PagesFragment :
spanResolver?.setGridSize(scale, requireViewBinding().recyclerView) spanResolver?.setGridSize(scale, requireViewBinding().recyclerView)
} }
private fun onNoChaptersChanged(isNoChapters: Boolean) { private fun onNoChaptersChanged(reason: EmptyMangaReason?) {
with(viewBinding ?: return) { with(viewBinding ?: return) {
textViewHolder.isVisible = isNoChapters textViewHolder.setTextAndVisible(reason?.msgResId ?: 0)
recyclerView.isInvisible = isNoChapters recyclerView.isInvisible = reason != null
} }
} }

View File

@@ -5,8 +5,9 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
@@ -47,16 +48,15 @@ class PagesViewModel @Inject constructor(
) )
init { init {
loadingJob = launchLoadingJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val firstState = state.firstNotNull() state.filterNotNull()
doInit(firstState) .collect {
launchJob(Dispatchers.Default) { val prevJob = loadingJob
state.collectLatest { loadingJob = launchLoadingJob(Dispatchers.Default) {
if (it != null) { prevJob?.cancelAndJoin()
doInit(it) doInit(it)
} }
} }
}
} }
} }

View File

@@ -6,6 +6,7 @@ import android.content.Context
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.work.BackoffPolicy import androidx.work.BackoffPolicy
import androidx.work.Constraints import androidx.work.Constraints
@@ -64,16 +65,20 @@ import org.koitharu.kotatsu.core.util.ext.ensureSuccess
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getWorkInputData import org.koitharu.kotatsu.core.util.ext.getWorkInputData
import org.koitharu.kotatsu.core.util.ext.getWorkSpec import org.koitharu.kotatsu.core.util.ext.getWorkSpec
import org.koitharu.kotatsu.core.util.ext.openSource
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.core.util.ext.toMimeType import org.koitharu.kotatsu.core.util.ext.toMimeType
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
import org.koitharu.kotatsu.core.util.ext.withTicker import org.koitharu.kotatsu.core.util.ext.withTicker
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.core.util.progress.RealtimeEtaEstimator import org.koitharu.kotatsu.core.util.progress.RealtimeEtaEstimator
import org.koitharu.kotatsu.download.domain.DownloadProgress import org.koitharu.kotatsu.download.domain.DownloadProgress
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageCache
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PageCache
import org.koitharu.kotatsu.local.data.TempFileFilter import org.koitharu.kotatsu.local.data.TempFileFilter
import org.koitharu.kotatsu.local.data.input.LocalMangaParser import org.koitharu.kotatsu.local.data.input.LocalMangaParser
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
@@ -99,7 +104,7 @@ class DownloadWorker @AssistedInject constructor(
@Assisted appContext: Context, @Assisted appContext: Context,
@Assisted params: WorkerParameters, @Assisted params: WorkerParameters,
@MangaHttpClient private val okHttp: OkHttpClient, @MangaHttpClient private val okHttp: OkHttpClient,
private val cache: PagesCache, @PageCache private val cache: LocalStorageCache,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val mangaLock: MangaLock, private val mangaLock: MangaLock,
private val mangaDataRepository: MangaDataRepository, private val mangaDataRepository: MangaDataRepository,
@@ -229,7 +234,7 @@ class DownloadWorker @AssistedInject constructor(
semaphore.withPermit { semaphore.withPermit {
runFailsafe { runFailsafe {
val url = repo.getPageUrl(page) val url = repo.getPageUrl(page)
val file = cache.get(url) val file = cache[url]
?: downloadFile(url, destination, repo.source) ?: downloadFile(url, destination, repo.source)
output.addPage( output.addPage(
chapter = chapter, chapter = chapter,
@@ -371,6 +376,25 @@ class DownloadWorker @AssistedInject constructor(
destination: File, destination: File,
source: MangaSource, source: MangaSource,
): File { ): File {
if (url.startsWith("content:", ignoreCase = true) || url.startsWith("file:", ignoreCase = true)) {
val uri = url.toUri()
val cr = applicationContext.contentResolver
val ext = uri.toFileOrNull()?.let {
MimeTypes.getNormalizedExtension(it.name)
} ?: cr.getType(uri)?.toMimeTypeOrNull()?.let { MimeTypes.getExtension(it) }
val file = destination.createTempFile(ext)
try {
cr.openSource(uri).use { input ->
file.sink(append = false).buffer().use {
it.writeAllCancellable(input)
}
}
} catch (e: Exception) {
file.delete()
throw e
}
return file
}
val request = PageLoader.createPageRequest(url, source) val request = PageLoader.createPageRequest(url, source)
slowdownDispatcher.delay(source) slowdownDispatcher.delay(source)
return imageProxyInterceptor.interceptPageRequest(request, okHttp) return imageProxyInterceptor.interceptPageRequest(request, okHttp)
@@ -379,22 +403,14 @@ class DownloadWorker @AssistedInject constructor(
var file: File? = null var file: File? = null
try { try {
response.requireBody().use { body -> response.requireBody().use { body ->
file = File( file = destination.createTempFile(
destination, ext = MimeTypes.getExtension(body.contentType()?.toMimeType())
buildString {
append(UUID.randomUUID().toString())
MimeTypes.getExtension(body.contentType()?.toMimeType())?.let { ext ->
append('.')
append(ext)
}
append(".tmp")
},
) )
file.sink(append = false).buffer().use { file.sink(append = false).buffer().use {
it.writeAllCancellable(body.source()) it.writeAllCancellable(body.source())
} }
} }
} catch (e: CancellationException) { } catch (e: Exception) {
file?.delete() file?.delete()
throw e throw e
} }
@@ -402,6 +418,18 @@ class DownloadWorker @AssistedInject constructor(
} }
} }
private fun File.createTempFile(ext: String?) = File(
this,
buildString {
append(UUID.randomUUID().toString())
if (!ext.isNullOrEmpty()) {
append('.')
append(ext)
}
append(".tmp")
},
)
private suspend fun publishState(state: DownloadState) { private suspend fun publishState(state: DownloadState) {
val previousState = currentState val previousState = currentState
lastPublishedState = state lastPublishedState = state
@@ -537,7 +565,7 @@ class DownloadWorker @AssistedInject constructor(
return return
} }
val requests = tasks.map { (manga, task) -> val requests = tasks.map { (manga, task) ->
mangaDataRepository.storeManga(manga) mangaDataRepository.storeManga(manga, replaceExisting = true)
OneTimeWorkRequestBuilder<DownloadWorker>() OneTimeWorkRequestBuilder<DownloadWorker>()
.setConstraints(createConstraints(task.allowMeteredNetwork)) .setConstraints(createConstraints(task.allowMeteredNetwork))
.addTag(TAG) .addTag(TAG)

View File

@@ -35,7 +35,7 @@ class ExploreRepository @Inject constructor(
val details = runCatchingCancellable { val details = runCatchingCancellable {
mangaRepositoryFactory.create(manga.source).getDetails(manga) mangaRepositoryFactory.create(manga.source).getDetails(manga)
}.getOrNull() ?: continue }.getOrNull() ?: continue
if ((settings.isSuggestionsExcludeNsfw && details.isNsfw) || details in tagsBlacklist) { if ((settings.isSuggestionsExcludeNsfw && details.isNsfw()) || details in tagsBlacklist) {
continue continue
} }
return details return details
@@ -55,7 +55,7 @@ class ExploreRepository @Inject constructor(
val details = runCatchingCancellable { val details = runCatchingCancellable {
mangaRepositoryFactory.create(manga.source).getDetails(manga) mangaRepositoryFactory.create(manga.source).getDetails(manga)
}.getOrNull() ?: continue }.getOrNull() ?: continue
if ((skipNsfw && details.isNsfw) || details in tagsBlacklist) { if ((skipNsfw && details.isNsfw()) || details in tagsBlacklist) {
continue continue
} }
return details return details
@@ -80,7 +80,7 @@ class ExploreRepository @Inject constructor(
filter = MangaListFilter(tags = setOfNotNull(tag)), filter = MangaListFilter(tags = setOfNotNull(tag)),
).asArrayList() ).asArrayList()
if (settings.isSuggestionsExcludeNsfw) { if (settings.isSuggestionsExcludeNsfw) {
list.removeAll { it.isNsfw } list.removeAll { it.isNsfw() }
} }
if (blacklist.isNotEmpty()) { if (blacklist.isNotEmpty()) {
list.removeAll { manga -> manga in blacklist } list.removeAll { manga -> manga in blacklist }

View File

@@ -24,7 +24,7 @@ class RecoverMangaUseCase @Inject constructor(
repository.getDetails(it) repository.getDetails(it)
} ?: return@runCatchingCancellable null } ?: return@runCatchingCancellable null
val merged = merge(manga, newManga) val merged = merge(manga, newManga)
mangaDataRepository.storeManga(merged) mangaDataRepository.storeManga(merged, replaceExisting = true)
merged merged
}.onFailure { }.onFailure {
it.printStackTraceDebug() it.printStackTraceDebug()

View File

@@ -198,11 +198,9 @@ class ExploreViewModel @Inject constructor(
private fun List<Manga>.toRecommendationList() = map { manga -> private fun List<Manga>.toRecommendationList() = map { manga ->
MangaCompactListModel( MangaCompactListModel(
id = manga.id,
title = manga.title,
subtitle = manga.tags.joinToString { it.title },
coverUrl = manga.coverUrl,
manga = manga, manga = manga,
override = null,
subtitle = manga.tags.joinToString { it.title },
counter = 0, counter = 0,
) )
} }

View File

@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.drawableStart
import org.koitharu.kotatsu.core.util.ext.recyclerView import org.koitharu.kotatsu.core.util.ext.recyclerView
import org.koitharu.kotatsu.core.util.ext.setProgressIcon import org.koitharu.kotatsu.core.util.ext.setProgressIcon
import org.koitharu.kotatsu.core.util.ext.setTooltipCompat
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding
import org.koitharu.kotatsu.databinding.ItemExploreSourceGridBinding import org.koitharu.kotatsu.databinding.ItemExploreSourceGridBinding
@@ -126,8 +127,7 @@ fun exploreSourceGridItemAD(
bind { bind {
val title = item.source.getTitle(context) val title = item.source.getTitle(context)
TooltipCompat.setTooltipText( itemView.setTooltipCompat(
itemView,
buildSpannedString { buildSpannedString {
bold { bold {
append(title) append(title)

View File

@@ -11,6 +11,7 @@ import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
import org.koitharu.kotatsu.core.util.ext.setTooltipCompat
import org.koitharu.kotatsu.databinding.ItemCategoriesAllBinding import org.koitharu.kotatsu.databinding.ItemCategoriesAllBinding
import org.koitharu.kotatsu.databinding.ItemCategoryBinding import org.koitharu.kotatsu.databinding.ItemCategoryBinding
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
@@ -91,6 +92,13 @@ fun allCategoriesAD(
R.drawable.ic_eye_off R.drawable.ic_eye_off
}, },
) )
binding.imageViewVisible.setTooltipCompat(
if (item.isVisible) {
R.string.hide
} else {
R.string.show
},
)
binding.coversView.setCoversAsync(item.covers) binding.coversView.setCoversAsync(item.covers)
} }
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.filter.ui package org.koitharu.kotatsu.filter.ui
import androidx.fragment.app.Fragment
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.ViewModelLifecycle import dagger.hilt.android.ViewModelLifecycle
import dagger.hilt.android.scopes.ViewModelScoped import dagger.hilt.android.scopes.ViewModelScoped
@@ -489,9 +490,27 @@ class FilterCoordinator @Inject constructor(
val filterCoordinator: FilterCoordinator val filterCoordinator: FilterCoordinator
} }
private companion object { companion object {
const val TAGS_LIMIT = 12 private const val TAGS_LIMIT = 12
val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1 private val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1
}
fun find(fragment: Fragment): FilterCoordinator? {
(fragment.activity as? Owner)?.let {
return it.filterCoordinator
}
var f = fragment
while (true) {
(f as? Owner)?.let {
return it.filterCoordinator
}
f = f.parentFragment ?: break
}
return null
}
fun require(fragment: Fragment): FilterCoordinator {
return find(fragment) ?: throw IllegalStateException("FilterCoordinator cannot be found")
}
}
} }

View File

@@ -55,7 +55,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
binding.scrollView.scrollIndicators = 0 binding.scrollView.scrollIndicators = 0
} }
} }
val filter = requireFilter() val filter = FilterCoordinator.require(this)
filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged) filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged) filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged)
filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged) filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged)
@@ -103,7 +103,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
} }
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
val filter = requireFilter() val filter = FilterCoordinator.require(this)
when (parent.id) { when (parent.id) {
R.id.spinner_order -> filter.setSortOrder(filter.sortOrder.value.availableItems[position]) R.id.spinner_order -> filter.setSortOrder(filter.sortOrder.value.availableItems[position])
R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position]) R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position])
@@ -118,7 +118,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
return return
} }
val intValue = value.toInt() val intValue = value.toInt()
val filter = requireFilter() val filter = FilterCoordinator.require(this)
when (slider.id) { when (slider.id) {
R.id.slider_year -> filter.setYear( R.id.slider_year -> filter.setYear(
if (intValue <= slider.valueFrom.toIntUp()) { if (intValue <= slider.valueFrom.toIntUp()) {
@@ -134,7 +134,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
if (!fromUser) { if (!fromUser) {
return return
} }
val filter = requireFilter() val filter = FilterCoordinator.require(this)
when (slider.id) { when (slider.id) {
R.id.slider_yearsRange -> filter.setYearRange( R.id.slider_yearsRange -> filter.setYearRange(
valueFrom = slider.values.firstOrNull()?.let { valueFrom = slider.values.firstOrNull()?.let {
@@ -148,7 +148,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
} }
override fun onChipClick(chip: Chip, data: Any?) { override fun onChipClick(chip: Chip, data: Any?) {
val filter = requireFilter() val filter = FilterCoordinator.require(this)
when (data) { when (data) {
is MangaState -> filter.toggleState(data, !chip.isChecked) is MangaState -> filter.toggleState(data, !chip.isChecked)
is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) { is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) {
@@ -356,6 +356,4 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
) )
b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo) b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo)
} }
private fun requireFilter() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator
} }

View File

@@ -36,7 +36,7 @@ class TagsCatalogSheet : BaseAdaptiveSheet<SheetTagsBinding>(),
extrasProducer = { extrasProducer = {
defaultViewModelCreationExtras.withCreationCallback<TagsCatalogViewModel.Factory> { factory -> defaultViewModelCreationExtras.withCreationCallback<TagsCatalogViewModel.Factory> { factory ->
factory.create( factory.create(
filter = (requireActivity() as FilterCoordinator.Owner).filterCoordinator, filter = FilterCoordinator.require(this),
isExcludeTag = requireArguments().getBoolean(AppRouter.KEY_EXCLUDE), isExcludeTag = requireArguments().getBoolean(AppRouter.KEY_EXCLUDE),
) )
} }

View File

@@ -4,9 +4,7 @@ import androidx.room.withTransaction
import dagger.Reusable import dagger.Reusable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
@@ -118,7 +116,7 @@ class HistoryRepository @Inject constructor(
} }
assert(manga.chapters != null) assert(manga.chapters != null)
db.withTransaction { db.withTransaction {
mangaRepository.storeManga(manga) mangaRepository.storeManga(manga, replaceExisting = true)
val branch = manga.chapters?.findById(chapterId)?.branch val branch = manga.chapters?.findById(chapterId)?.branch
db.getHistoryDao().upsert( db.getHistoryDao().upsert(
HistoryEntity( HistoryEntity(
@@ -204,9 +202,7 @@ class HistoryRepository @Inject constructor(
fun shouldSkip(manga: Manga): Boolean = settings.isIncognitoModeEnabled(manga.isNsfw()) fun shouldSkip(manga: Manga): Boolean = settings.isIncognitoModeEnabled(manga.isNsfw())
fun observeShouldSkip(manga: Manga): Flow<Boolean> { fun observeShouldSkip(manga: Manga): Flow<Boolean> {
return settings.observe() return settings.observe(AppSettings.KEY_INCOGNITO_MODE, AppSettings.KEY_INCOGNITO_NSFW)
.filter { key -> key == AppSettings.KEY_INCOGNITO_MODE || key == AppSettings.KEY_INCOGNITO_NSFW }
.onStart { emit("") }
.map { shouldSkip(manga) } .map { shouldSkip(manga) }
.distinctUntilChanged() .distinctUntilChanged()
} }

View File

@@ -1,13 +1,16 @@
package org.koitharu.kotatsu.image.ui package org.koitharu.kotatsu.image.ui
import android.content.Context import android.content.Context
import android.graphics.drawable.LayerDrawable
import android.os.Build import android.os.Build
import android.util.AttributeSet import android.util.AttributeSet
import android.view.Gravity
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
import android.view.ViewTreeObserver.OnPreDrawListener import android.view.ViewTreeObserver.OnPreDrawListener
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes import androidx.core.content.withStyledAttributes
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.drawable.toDrawable
@@ -33,6 +36,7 @@ import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.util.ext.bookmarkExtra import org.koitharu.kotatsu.core.util.ext.bookmarkExtra
import org.koitharu.kotatsu.core.util.ext.decodeRegion import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isNetworkError
import org.koitharu.kotatsu.core.util.ext.mangaExtra import org.koitharu.kotatsu.core.util.ext.mangaExtra
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.favourites.domain.model.Cover
@@ -185,8 +189,17 @@ class CoverImageView @JvmOverloads constructor(
override fun onError(request: ImageRequest, result: ErrorResult) { override fun onError(request: ImageRequest, result: ErrorResult) {
super.onError(request, result) super.onError(request, result)
foreground = result.throwable.getShortMessage()?.let { text -> foreground = if (result.throwable.isNetworkError() && !networkState.isOnline()) {
TextDrawable.create(context, text, materialR.attr.textAppearanceTitleSmall) ContextCompat.getDrawable(context, R.drawable.ic_offline)?.let {
LayerDrawable(arrayOf(it)).apply {
setLayerGravity(0, Gravity.CENTER)
setTint(ContextCompat.getColor(context, R.color.dim_lite))
}
}
} else {
result.throwable.getShortMessage()?.let { text ->
TextDrawable.create(context, text, materialR.attr.textAppearanceTitleSmall)
}
} }
} }

View File

@@ -23,8 +23,9 @@ import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import org.koitharu.kotatsu.tracker.ui.feed.model.FeedItem
import javax.inject.Inject import javax.inject.Inject
@Reusable @Reusable
@@ -77,6 +78,14 @@ class MangaListMapper @Inject constructor(
override = dataRepository.getOverride(manga.id), override = dataRepository.getOverride(manga.id),
) )
suspend fun toFeedItem(logItem: TrackingLogItem) = FeedItem(
id = logItem.id,
override = dataRepository.getOverride(logItem.manga.id),
count = logItem.chapters.size,
manga = logItem.manga,
isNew = logItem.isNew,
)
fun mapTags(tags: Collection<MangaTag>) = tags.map { fun mapTags(tags: Collection<MangaTag>) = tags.map {
ChipsView.ChipModel( ChipsView.ChipModel(
tint = getTagTint(it), tint = getTagTint(it),
@@ -90,11 +99,9 @@ class MangaListMapper @Inject constructor(
@Options options: Int, @Options options: Int,
override: MangaOverride?, override: MangaOverride?,
) = MangaCompactListModel( ) = MangaCompactListModel(
id = manga.id,
title = override?.title.ifNullOrEmpty { manga.title },
subtitle = manga.tags.joinToString(", ") { it.title },
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
manga = manga, manga = manga,
override = override,
subtitle = manga.tags.joinToString(", ") { it.title },
counter = getCounter(manga.id, options), counter = getCounter(manga.id, options),
) )
@@ -103,11 +110,9 @@ class MangaListMapper @Inject constructor(
@Options options: Int, @Options options: Int,
override: MangaOverride?, override: MangaOverride?,
) = MangaDetailedListModel( ) = MangaDetailedListModel(
id = manga.id,
title = override?.title.ifNullOrEmpty { manga.title },
subtitle = manga.altTitles.firstOrNull(), subtitle = manga.altTitles.firstOrNull(),
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
manga = manga, manga = manga,
override = override,
counter = getCounter(manga.id, options), counter = getCounter(manga.id, options),
progress = getProgress(manga.id, options), progress = getProgress(manga.id, options),
isFavorite = isFavorite(manga.id, options), isFavorite = isFavorite(manga.id, options),
@@ -120,10 +125,8 @@ class MangaListMapper @Inject constructor(
@Options options: Int, @Options options: Int,
override: MangaOverride? override: MangaOverride?
) = MangaGridModel( ) = MangaGridModel(
id = manga.id,
title = override?.title.ifNullOrEmpty { manga.title },
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
manga = manga, manga = manga,
override = override,
counter = getCounter(manga.id, options), counter = getCounter(manga.id, options),
progress = getProgress(manga.id, options), progress = getProgress(manga.id, options),
isFavorite = isFavorite(manga.id, options), isFavorite = isFavorite(manga.id, options),

View File

@@ -153,19 +153,20 @@ abstract class MangaListFragment :
super.onDestroyView() super.onDestroyView()
} }
override fun onItemClick(item: Manga, view: View) { override fun onItemClick(item: MangaListModel, view: View) {
if (selectionController?.onItemClick(item.id) != true) { if (selectionController?.onItemClick(item.id) != true) {
if ((activity as? MangaListActivity)?.showPreview(item) != true) { val manga = item.toMangaWithOverride()
router.openDetails(item) if ((activity as? MangaListActivity)?.showPreview(manga) != true) {
router.openDetails(manga)
} }
} }
} }
override fun onItemLongClick(item: Manga, view: View): Boolean { override fun onItemLongClick(item: MangaListModel, view: View): Boolean {
return selectionController?.onItemLongClick(view, item.id) == true return selectionController?.onItemLongClick(view, item.id) == true
} }
override fun onItemContextClick(item: Manga, view: View): Boolean { override fun onItemContextClick(item: MangaListModel, view: View): Boolean {
return selectionController?.onItemContextClick(view, item.id) == true return selectionController?.onItemContextClick(view, item.id) == true
} }

View File

@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
@@ -45,7 +46,7 @@ abstract class MangaListViewModel(
abstract fun onRetry() abstract fun onRetry()
protected fun List<Manga>.skipNsfwIfNeeded() = if (settings.isNsfwContentDisabled) { protected fun List<Manga>.skipNsfwIfNeeded() = if (settings.isNsfwContentDisabled) {
filterNot { it.isNsfw } filterNot { it.isNsfw() }
} else { } else {
this this
} }
@@ -63,7 +64,7 @@ abstract class MangaListViewModel(
protected fun observeListModeWithTriggers(): Flow<ListMode> = combine( protected fun observeListModeWithTriggers(): Flow<ListMode> = combine(
listMode, listMode,
mangaDataRepository.observeOverridesTrigger(emitInitialState = true), mangaDataRepository.observeOverridesTrigger(emitInitialState = true),
settings.observe().filter { key -> settings.observeChanges().filter { key ->
key == AppSettings.KEY_PROGRESS_INDICATORS key == AppSettings.KEY_PROGRESS_INDICATORS
|| key == AppSettings.KEY_TRACKER_ENABLED || key == AppSettings.KEY_TRACKER_ENABLED
|| key == AppSettings.KEY_QUICK_FILTER || key == AppSettings.KEY_QUICK_FILTER

View File

@@ -2,10 +2,11 @@ package org.koitharu.kotatsu.list.ui.adapter
import android.view.View import android.view.View
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
interface MangaDetailsClickListener : OnListItemClickListener<Manga> { interface MangaDetailsClickListener : OnListItemClickListener<MangaListModel> {
fun onReadClick(manga: Manga, view: View) fun onReadClick(manga: Manga, view: View)

View File

@@ -1,30 +1,30 @@
package org.koitharu.kotatsu.list.ui.adapter package org.koitharu.kotatsu.list.ui.adapter
import androidx.appcompat.widget.TooltipCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.setTooltipCompat
import org.koitharu.kotatsu.databinding.ItemMangaGridBinding import org.koitharu.kotatsu.databinding.ItemMangaGridBinding
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver
import org.koitharu.kotatsu.parsers.model.Manga
fun mangaGridItemAD( fun mangaGridItemAD(
sizeResolver: ItemSizeResolver, sizeResolver: ItemSizeResolver,
clickListener: OnListItemClickListener<Manga>, clickListener: OnListItemClickListener<MangaListModel>,
) = adapterDelegateViewBinding<MangaGridModel, ListModel, ItemMangaGridBinding>( ) = adapterDelegateViewBinding<MangaGridModel, ListModel, ItemMangaGridBinding>(
{ inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) }, { inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) },
) { ) {
AdapterDelegateClickListenerAdapter(this, clickListener, MangaGridModel::manga).attach(itemView) AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
sizeResolver.attachToView(itemView, binding.textViewTitle, binding.progressView) sizeResolver.attachToView(itemView, binding.textViewTitle, binding.progressView)
bind { payloads -> bind { payloads ->
TooltipCompat.setTooltipText(itemView, item.getSummary(context)) itemView.setTooltipCompat(item.getSummary(context))
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title
binding.progressView.setProgress(item.progress, PAYLOAD_PROGRESS_CHANGED in payloads) binding.progressView.setProgress(item.progress, PAYLOAD_PROGRESS_CHANGED in payloads)
with(binding.iconsView) { with(binding.iconsView) {

View File

@@ -16,7 +16,8 @@ fun mangaListDetailedItemAD(
{ inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) }, { inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) },
) { ) {
AdapterDelegateClickListenerAdapter(this, clickListener, MangaDetailedListModel::manga).attach(itemView) AdapterDelegateClickListenerAdapter(this, clickListener)
.attach(itemView)
bind { payloads -> bind { payloads ->
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title

View File

@@ -1,26 +1,26 @@
package org.koitharu.kotatsu.list.ui.adapter package org.koitharu.kotatsu.list.ui.adapter
import androidx.appcompat.widget.TooltipCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.setTooltipCompat
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemMangaListBinding import org.koitharu.kotatsu.databinding.ItemMangaListBinding
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.list.ui.model.MangaListModel
fun mangaListItemAD( fun mangaListItemAD(
clickListener: OnListItemClickListener<Manga>, clickListener: OnListItemClickListener<MangaListModel>,
) = adapterDelegateViewBinding<MangaCompactListModel, ListModel, ItemMangaListBinding>( ) = adapterDelegateViewBinding<MangaCompactListModel, ListModel, ItemMangaListBinding>(
{ inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) }, { inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) },
) { ) {
AdapterDelegateClickListenerAdapter(this, clickListener, MangaCompactListModel::manga).attach(itemView) AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
bind { bind {
TooltipCompat.setTooltipText(itemView, item.getSummary(context)) itemView.setTooltipCompat(item.getSummary(context))
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title
binding.textViewSubtitle.textAndVisible = item.subtitle binding.textViewSubtitle.textAndVisible = item.subtitle
binding.imageViewCover.setImageAsync(item.coverUrl, item.manga) binding.imageViewCover.setImageAsync(item.coverUrl, item.manga)

View File

@@ -1,12 +1,11 @@
package org.koitharu.kotatsu.list.ui.model package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.core.ui.model.MangaOverride
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
data class MangaCompactListModel( data class MangaCompactListModel(
override val id: Long,
override val title: String,
val subtitle: String,
override val coverUrl: String?,
override val manga: Manga, override val manga: Manga,
override val override: MangaOverride?,
val subtitle: String,
override val counter: Int, override val counter: Int,
) : MangaListModel() ) : MangaListModel()

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.list.ui.model package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.core.ui.model.MangaOverride
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.list.domain.ReadingProgress import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED
@@ -7,11 +8,9 @@ import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROG
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
data class MangaDetailedListModel( data class MangaDetailedListModel(
override val id: Long,
override val title: String,
val subtitle: String?,
override val coverUrl: String?,
override val manga: Manga, override val manga: Manga,
override val override: MangaOverride?,
val subtitle: String?,
override val counter: Int, override val counter: Int,
val progress: ReadingProgress?, val progress: ReadingProgress?,
val isFavorite: Boolean, val isFavorite: Boolean,

View File

@@ -1,15 +1,14 @@
package org.koitharu.kotatsu.list.ui.model package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.core.ui.model.MangaOverride
import org.koitharu.kotatsu.list.domain.ReadingProgress import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
data class MangaGridModel( data class MangaGridModel(
override val id: Long,
override val title: String,
override val coverUrl: String?,
override val manga: Manga, override val manga: Manga,
override val override: MangaOverride?,
override val counter: Int, override val counter: Int,
val progress: ReadingProgress?, val progress: ReadingProgress?,
val isFavorite: Boolean, val isFavorite: Boolean,

View File

@@ -4,21 +4,33 @@ import android.content.Context
import androidx.core.text.bold import androidx.core.text.bold
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString
import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.withOverride
import org.koitharu.kotatsu.core.ui.model.MangaOverride
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
sealed class MangaListModel : ListModel { sealed class MangaListModel : ListModel {
abstract val id: Long abstract val override: MangaOverride?
abstract val manga: Manga abstract val manga: Manga
abstract val title: String
abstract val coverUrl: String?
abstract val counter: Int abstract val counter: Int
val id: Long
get() = manga.id
val title: String
get() = override?.title.ifNullOrEmpty { manga.title }
val coverUrl: String?
get() = override?.coverUrl.ifNullOrEmpty { manga.coverUrl }
val source: MangaSource val source: MangaSource
get() = manga.source get() = manga.source
fun toMangaWithOverride() = manga.withOverride(override)
open fun getSummary(context: Context): CharSequence = buildSpannedString { open fun getSummary(context: Context): CharSequence = buildSpannedString {
bold { bold {
append(manga.title) append(manga.title)

View File

@@ -74,7 +74,7 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
override fun onChipClick(chip: Chip, data: Any?) { override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag ?: return val tag = data as? MangaTag ?: return
val filter = (activity as? FilterCoordinator.Owner)?.filterCoordinator val filter = FilterCoordinator.find(this)
if (filter == null) { if (filter == null) {
router.openList(tag) router.openList(tag)
} else { } else {

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.local.data
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class PageCache
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class FaviconCache

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.AlphanumComparator import org.koitharu.kotatsu.core.util.AlphanumComparator
@@ -94,7 +95,7 @@ class LocalMangaRepository @Inject constructor(
} }
val list = getRawList() val list = getRawList()
if (settings.isNsfwContentDisabled) { if (settings.isNsfwContentDisabled) {
list.removeAll { it.manga.isNsfw } list.removeAll { it.manga.isNsfw() }
} }
if (filter != null) { if (filter != null) {
val query = filter.query val query = filter.query
@@ -109,7 +110,7 @@ class LocalMangaRepository @Inject constructor(
} }
filter.contentRating.singleOrNull()?.let { contentRating -> filter.contentRating.singleOrNull()?.let { contentRating ->
val isNsfw = contentRating == ContentRating.ADULT val isNsfw = contentRating == ContentRating.ADULT
list.retainAll { it.manga.isNsfw == isNsfw } list.retainAll { it.manga.isNsfw() == isNsfw }
} }
if (!query.isNullOrEmpty() && order == SortOrder.RELEVANCE) { if (!query.isNullOrEmpty() && order == SortOrder.RELEVANCE) {
list.sortBy { it.manga.title.levenshteinDistance(query) } list.sortBy { it.manga.title.levenshteinDistance(query) }

View File

@@ -5,7 +5,6 @@ import android.graphics.Bitmap
import android.os.StatFs import android.os.StatFs
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import com.tomclaw.cache.DiskLruCache import com.tomclaw.cache.DiskLruCache
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -14,7 +13,6 @@ import okio.buffer
import okio.sink import okio.sink
import okio.use import okio.use
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.ext.MimeType import org.koitharu.kotatsu.core.util.ext.MimeType
import org.koitharu.kotatsu.core.util.ext.compressToPNG import org.koitharu.kotatsu.core.util.ext.compressToPNG
@@ -28,22 +26,24 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class LocalStorageCache(
class PagesCache @Inject constructor(@ApplicationContext context: Context) { context: Context,
private val dir: CacheDir,
private val defaultSize: Long,
private val minSize: Long,
) {
private val cacheDir = suspendLazy { private val cacheDir = suspendLazy {
val dirs = context.externalCacheDirs + context.cacheDir val dirs = context.externalCacheDirs + context.cacheDir
dirs.firstNotNullOf { dirs.firstNotNullOf {
it?.subdir(CacheDir.PAGES.dir)?.takeIfWriteable() it?.subdir(dir.dir)?.takeIfWriteable()
} }
} }
private val lruCache = suspendLazy { private val lruCache = suspendLazy {
val dir = cacheDir.get() val dir = cacheDir.get()
val availableSize = (getAvailableSize() * 0.8).toLong() val availableSize = (getAvailableSize() * 0.8).toLong()
val size = SIZE_DEFAULT.coerceAtMost(availableSize).coerceAtLeast(SIZE_MIN) val size = defaultSize.coerceAtMost(availableSize).coerceAtLeast(minSize)
runCatchingCancellable { runCatchingCancellable {
DiskLruCache.create(dir, size) DiskLruCache.create(dir, size)
}.recoverCatching { error -> }.recoverCatching { error ->
@@ -54,14 +54,14 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
}.getOrThrow() }.getOrThrow()
} }
suspend fun get(url: String): File? = withContext(Dispatchers.IO) { suspend operator fun get(url: String): File? = withContext(Dispatchers.IO) {
val cache = lruCache.get() val cache = lruCache.get()
runInterruptible { runInterruptible {
cache.get(url)?.takeIfReadable() cache.get(url)?.takeIfReadable()
} }
} }
suspend fun put(url: String, source: Source, mimeType: MimeType?): File = withContext(Dispatchers.IO) { suspend operator fun set(url: String, source: Source, mimeType: MimeType?): File = withContext(Dispatchers.IO) {
val file = createBufferFile(url, mimeType) val file = createBufferFile(url, mimeType)
try { try {
val bytes = file.sink(append = false).buffer().use { val bytes = file.sink(append = false).buffer().use {
@@ -79,7 +79,7 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
} }
} }
suspend fun put(url: String, bitmap: Bitmap): File = withContext(Dispatchers.IO) { suspend operator fun set(url: String, bitmap: Bitmap): File = withContext(Dispatchers.IO) {
val file = createBufferFile(url, MimeType("image/png")) val file = createBufferFile(url, MimeType("image/png"))
try { try {
bitmap.compressToPNG(file) bitmap.compressToPNG(file)
@@ -107,7 +107,7 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
} }
}.onFailure { }.onFailure {
it.printStackTraceDebug() it.printStackTraceDebug()
}.getOrDefault(SIZE_DEFAULT) }.getOrDefault(defaultSize)
private suspend fun createBufferFile(url: String, mimeType: MimeType?): File { private suspend fun createBufferFile(url: String, mimeType: MimeType?): File {
val ext = MimeTypes.getExtension(mimeType) ?: MimeTypeMap.getFileExtensionFromUrl(url).ifNullOrEmpty { "dat" } val ext = MimeTypes.getExtension(mimeType) ?: MimeTypeMap.getFileExtensionFromUrl(url).ifNullOrEmpty { "dat" }
@@ -116,13 +116,4 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
val name = UUID.randomUUID().toString() + "." + ext val name = UUID.randomUUID().toString() + "." + ext
return File(rootDir, name) return File(rootDir, name)
} }
private companion object {
val SIZE_MIN
get() = FileSize.MEGABYTES.convert(20, FileSize.BYTES)
val SIZE_DEFAULT
get() = FileSize.MEGABYTES.convert(200, FileSize.BYTES)
}
} }

View File

@@ -11,8 +11,8 @@ import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okio.buffer import okio.buffer
import okio.sink import okio.sink
import okio.source
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.util.ext.openSource
import org.koitharu.kotatsu.core.util.ext.resolveName import org.koitharu.kotatsu.core.util.ext.resolveName
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
@@ -51,12 +51,12 @@ class SingleMangaImporter @Inject constructor(
} }
val dest = File(getOutputDir(), name) val dest = File(getOutputDir(), name)
runInterruptible { runInterruptible {
contentResolver.openInputStream(uri) contentResolver.openSource(uri)
}?.use { source -> }.use { source ->
dest.sink().buffer().use { output -> dest.sink().buffer().use { output ->
output.writeAllCancellable(source.source()) output.writeAllCancellable(source)
} }
} ?: throw IOException("Cannot open input stream: $uri") }
LocalMangaParser(dest).getManga(withDetails = false) LocalMangaParser(dest).getManga(withDetails = false)
} }
@@ -80,7 +80,7 @@ class SingleMangaImporter @Inject constructor(
docFile.copyTo(subDir) docFile.copyTo(subDir)
} }
} else { } else {
inputStream().source().use { input -> source().use { input ->
File(destDir, requireName()).sink().buffer().use { output -> File(destDir, requireName()).sink().buffer().use { output ->
output.writeAllCancellable(input) output.writeAllCancellable(input)
} }
@@ -92,8 +92,8 @@ class SingleMangaImporter @Inject constructor(
return storageManager.getDefaultWriteableDir() ?: throw IOException("External files dir unavailable") return storageManager.getDefaultWriteableDir() ?: throw IOException("External files dir unavailable")
} }
private suspend fun DocumentFile.inputStream() = runInterruptible(Dispatchers.IO) { private suspend fun DocumentFile.source() = runInterruptible(Dispatchers.IO) {
contentResolver.openInputStream(uri) ?: throw IOException("Cannot open input stream: $uri") contentResolver.openSource(uri)
} }
private fun DocumentFile.requireName(): String { private fun DocumentFile.requireName(): String {

View File

@@ -97,7 +97,7 @@ class LocalMangaIndex @Inject constructor(
} }
private suspend fun upsert(manga: LocalManga) { private suspend fun upsert(manga: LocalManga) {
mangaDataRepository.storeManga(manga.manga) mangaDataRepository.storeManga(manga.manga, replaceExisting = true)
db.getLocalMangaIndexDao().upsert(manga.toEntity()) db.getLocalMangaIndexDao().upsert(manga.toEntity())
} }

View File

@@ -23,7 +23,6 @@ import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.tryLaunch import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
@@ -45,13 +44,14 @@ class LocalListFragment : MangaListFragment(), FilterCoordinator.Owner {
} }
} }
init { override fun onCreate(savedInstanceState: Bundle?) {
withArgs(1) { super.onCreate(savedInstanceState)
putString( val args = arguments ?: Bundle(1)
RemoteListFragment.ARG_SOURCE, args.putString(
LocalMangaSource.name, RemoteListFragment.ARG_SOURCE,
) // required by FilterCoordinator LocalMangaSource.name,
} ) // required by FilterCoordinator
arguments = args
} }
override val viewModel by viewModels<LocalListViewModel>() override val viewModel by viewModels<LocalListViewModel>()

View File

@@ -6,10 +6,13 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.toChipModel
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.core.util.ext.toFileOrNull
@@ -17,10 +20,13 @@ import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.domain.QuickFilterListener
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.list.ui.model.QuickFilter
import org.koitharu.kotatsu.list.ui.model.TipModel import org.koitharu.kotatsu.list.ui.model.TipModel
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
@@ -52,9 +58,10 @@ class LocalListViewModel @Inject constructor(
exploreRepository = exploreRepository, exploreRepository = exploreRepository,
sourcesRepository = sourcesRepository, sourcesRepository = sourcesRepository,
mangaDataRepository = mangaDataRepository, mangaDataRepository = mangaDataRepository,
), SharedPreferences.OnSharedPreferenceChangeListener { ), SharedPreferences.OnSharedPreferenceChangeListener, QuickFilterListener {
val onMangaRemoved = MutableEventFlow<Unit>() val onMangaRemoved = MutableEventFlow<Unit>()
private val showInlineFilter: Boolean = savedStateHandle[AppRouter.KEY_IS_BOTTOMTAB] ?: false
init { init {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
@@ -68,29 +75,49 @@ class LocalListViewModel @Inject constructor(
override suspend fun onBuildList(list: MutableList<ListModel>) { override suspend fun onBuildList(list: MutableList<ListModel>) {
super.onBuildList(list) super.onBuildList(list)
if (localStorageManager.hasExternalStoragePermission(isReadOnly = true)) { if (showInlineFilter) {
return createFilterHeader(maxCount = 16)?.let {
} list.add(0, it)
for (item in list) {
if (item !is MangaListModel) {
continue
} }
val file = item.manga.url.toUriOrNull()?.toFileOrNull() ?: continue }
if (localStorageManager.isOnExternalStorage(file)) { if (!localStorageManager.hasExternalStoragePermission(isReadOnly = true)) {
val tip = TipModel( for (item in list) {
key = "permission", if (item !is MangaListModel) {
title = R.string.external_storage, continue
text = R.string.missing_storage_permission, }
icon = R.drawable.ic_storage, val file = item.manga.url.toUriOrNull()?.toFileOrNull() ?: continue
primaryButtonText = R.string.fix, if (localStorageManager.isOnExternalStorage(file)) {
secondaryButtonText = R.string.settings, val tip = TipModel(
) key = "permission",
list.add(0, tip) title = R.string.external_storage,
return text = R.string.missing_storage_permission,
icon = R.drawable.ic_storage,
primaryButtonText = R.string.fix,
secondaryButtonText = R.string.settings,
)
list.add(0, tip)
return
}
} }
} }
} }
override fun setFilterOption(option: ListFilterOption, isApplied: Boolean) {
if (option is ListFilterOption.Tag) {
filterCoordinator.toggleTag(option.tag, isApplied)
}
}
override fun toggleFilterOption(option: ListFilterOption) {
if (option is ListFilterOption.Tag) {
val tag = option.tag
val isSelected = tag in filterCoordinator.snapshot().listFilter.tags
filterCoordinator.toggleTag(option.tag, !isSelected)
}
}
override fun clearFilter() = filterCoordinator.reset()
override fun onCleared() { override fun onCleared() {
settings.unsubscribe(this) settings.unsubscribe(this)
super.onCleared() super.onCleared()
@@ -125,4 +152,26 @@ class LocalListViewModel @Inject constructor(
actionStringRes = R.string._import, actionStringRes = R.string._import,
) )
} }
private suspend fun createFilterHeader(maxCount: Int): QuickFilter? {
val appliedTags = filterCoordinator.snapshot().listFilter.tags
val availableTags = repository.getFilterOptions().availableTags
if (appliedTags.isEmpty() && availableTags.size < 3) {
return null
}
val result = ArrayList<ChipsView.ChipModel>(minOf(availableTags.size, maxCount))
appliedTags.mapTo(result) { tag ->
ListFilterOption.Tag(tag).toChipModel(isChecked = true)
}
for (tag in availableTags) {
if (result.size >= maxCount) {
break
}
if (tag in appliedTags) {
continue
}
result.add(ListFilterOption.Tag(tag).toChipModel(isChecked = false))
}
return QuickFilter(result)
}
} }

View File

@@ -1,10 +1,19 @@
package org.koitharu.kotatsu.local.ui package org.koitharu.kotatsu.local.ui
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.provider.Settings
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.work.BackoffPolicy import androidx.work.BackoffPolicy
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager import androidx.work.WorkManager
@@ -12,6 +21,8 @@ import androidx.work.WorkerParameters
import androidx.work.await import androidx.work.await
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
@@ -40,9 +51,63 @@ class LocalStorageCleanupWorker @AssistedInject constructor(
} }
} }
override suspend fun getForegroundInfo(): ForegroundInfo {
val title = applicationContext.getString(R.string.local_storage_cleanup)
val channel = NotificationChannelCompat.Builder(WORKER_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(title)
.setShowBadge(true)
.setVibrationEnabled(false)
.setSound(null, null)
.setLightsEnabled(true)
.build()
NotificationManagerCompat.from(applicationContext).createNotificationChannel(channel)
val notification = NotificationCompat.Builder(applicationContext, WORKER_CHANNEL_ID)
.setContentTitle(title)
.setContentIntent(
PendingIntentCompat.getActivity(
applicationContext,
0,
AppRouter.suggestionsSettingsIntent(applicationContext),
0,
false,
),
)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setDefaults(0)
.setOngoing(false)
.setSilent(true)
.setProgress(0, 0, true)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val actionIntent = PendingIntentCompat.getActivity(
applicationContext, SETTINGS_ACTION_CODE,
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, applicationContext.packageName)
.putExtra(Settings.EXTRA_CHANNEL_ID, WORKER_CHANNEL_ID),
0, false,
)
notification.addAction(
R.drawable.ic_settings,
applicationContext.getString(R.string.notifications_settings),
actionIntent,
)
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(WORKER_NOTIFICATION_ID, notification.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
ForegroundInfo(WORKER_NOTIFICATION_ID, notification.build())
}
}
companion object { companion object {
private const val TAG = "cleanup" private const val TAG = "cleanup"
private const val WORKER_CHANNEL_ID = "storage_cleanup"
private const val WORKER_NOTIFICATION_ID = 32
private const val SETTINGS_ACTION_CODE = 6
suspend fun enqueue(context: Context) { suspend fun enqueue(context: Context) {
val request = OneTimeWorkRequestBuilder<LocalStorageCleanupWorker>() val request = OneTimeWorkRequestBuilder<LocalStorageCleanupWorker>()

View File

@@ -72,7 +72,7 @@ class CoverRestoreInterceptor @Inject constructor(
val repo = repositoryFactory.create(manga.source) val repo = repositoryFactory.create(manga.source)
val fixed = repo.find(manga) ?: return false val fixed = repo.find(manga) ?: return false
return if (fixed != manga) { return if (fixed != manga) {
dataRepository.storeManga(fixed) dataRepository.storeManga(fixed, replaceExisting = true)
fixed.coverUrl != manga.coverUrl fixed.coverUrl != manga.coverUrl
} else { } else {
false false

View File

@@ -2,12 +2,13 @@ package org.koitharu.kotatsu.main.domain
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import javax.inject.Inject import javax.inject.Inject
@@ -17,15 +18,21 @@ class ReadingResumeEnabledUseCase @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
) { ) {
operator fun invoke(): Flow<Boolean> = settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { operator fun invoke(): Flow<Boolean> = settings.observe(
isIncognitoModeEnabled AppSettings.KEY_MAIN_FAB,
}.flatMapLatest { incognito -> AppSettings.KEY_INCOGNITO_MODE,
if (incognito) { ).map {
flowOf(false) settings.isMainFabEnabled && !settings.isIncognitoModeEnabled
} else { }.distinctUntilChanged()
combine(networkState, historyRepository.observeLast()) { isOnline, last -> .flatMapLatest { isFabEnabled ->
last != null && (isOnline || last.isLocal) if (isFabEnabled) {
observeCanResume()
} else {
flowOf(false)
} }
} }
}
private fun observeCanResume() = combine(networkState, historyRepository.observeLast()) { isOnline, last ->
last != null && (isOnline || last.isLocal)
}.distinctUntilChanged()
} }

View File

@@ -131,6 +131,12 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
onBackPressedDispatcher.addCallback(exitCallback) onBackPressedDispatcher.addCallback(exitCallback)
onBackPressedDispatcher.addCallback(navigationDelegate) onBackPressedDispatcher.addCallback(navigationDelegate)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
val legacySearchCallback = SearchViewLegacyBackCallback(viewBinding.searchView)
viewBinding.searchView.addTransitionListener(legacySearchCallback)
onBackPressedDispatcher.addCallback(legacySearchCallback)
}
if (savedInstanceState == null) { if (savedInstanceState == null) {
onFirstStart() onFirstStart()
} }
@@ -314,6 +320,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
topFragment: Fragment? = navigationDelegate.primaryFragment, topFragment: Fragment? = navigationDelegate.primaryFragment,
isSearchOpened: Boolean = viewBinding.searchView.isShowing, isSearchOpened: Boolean = viewBinding.searchView.isShowing,
) { ) {
navigationDelegate.navRailHeader?.railFab?.isVisible = isResumeEnabled
val fab = viewBinding.fab ?: return val fab = viewBinding.fab ?: return
if (isResumeEnabled && !actionModeDelegate.isActionModeStarted && !isSearchOpened && topFragment is HistoryListFragment) { if (isResumeEnabled && !actionModeDelegate.isActionModeStarted && !isSearchOpened && topFragment is HistoryListFragment) {
if (!fab.isVisible) { if (!fab.isVisible) {

View File

@@ -1,14 +1,18 @@
package org.koitharu.kotatsu.main.ui package org.koitharu.kotatsu.main.ui
import android.os.Bundle import android.os.Bundle
import android.view.Gravity
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.widget.FrameLayout
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.annotation.IdRes import androidx.annotation.IdRes
import androidx.core.view.isEmpty import androidx.core.view.isEmpty
import androidx.core.view.isVisible
import androidx.core.view.iterator import androidx.core.view.iterator
import androidx.core.view.size import androidx.core.view.size
import androidx.core.view.updateLayoutParams
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
@@ -16,22 +20,20 @@ import androidx.lifecycle.lifecycleScope
import com.google.android.material.navigation.NavigationBarView import com.google.android.material.navigation.NavigationBarView
import com.google.android.material.navigationrail.NavigationRailView import com.google.android.material.navigationrail.NavigationRailView
import com.google.android.material.transition.MaterialFadeThrough import com.google.android.material.transition.MaterialFadeThrough
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksFragment import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksFragment
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.NavItem import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView
import org.koitharu.kotatsu.core.util.ext.buildBundle
import org.koitharu.kotatsu.core.util.ext.setContentDescriptionAndTooltip import org.koitharu.kotatsu.core.util.ext.setContentDescriptionAndTooltip
import org.koitharu.kotatsu.core.util.ext.smoothScrollToTop import org.koitharu.kotatsu.core.util.ext.smoothScrollToTop
import org.koitharu.kotatsu.databinding.NavigationRailFabBinding import org.koitharu.kotatsu.databinding.NavigationRailFabBinding
@@ -56,7 +58,7 @@ class MainNavigationDelegate(
NavigationBarView.OnItemReselectedListener, View.OnClickListener { NavigationBarView.OnItemReselectedListener, View.OnClickListener {
private val listeners = LinkedList<OnFragmentChangedListener>() private val listeners = LinkedList<OnFragmentChangedListener>()
private val navRailHeader = (navBar as? NavigationRailView)?.headerView?.let { val navRailHeader = (navBar as? NavigationRailView)?.headerView?.let {
NavigationRailFabBinding.bind(it) NavigationRailFabBinding.bind(it)
} }
@@ -67,6 +69,9 @@ class MainNavigationDelegate(
navBar.setOnItemSelectedListener(this) navBar.setOnItemSelectedListener(this)
navBar.setOnItemReselectedListener(this) navBar.setOnItemReselectedListener(this)
navRailHeader?.run { navRailHeader?.run {
root.updateLayoutParams<FrameLayout.LayoutParams> {
gravity = Gravity.TOP or Gravity.CENTER
}
val horizontalPadding = (navBar as NavigationRailView).itemActiveIndicatorMarginHorizontal val horizontalPadding = (navBar as NavigationRailView).itemActiveIndicatorMarginHorizontal
root.setPadding(horizontalPadding, 0, horizontalPadding, 0) root.setPadding(horizontalPadding, 0, horizontalPadding, 0)
buttonExpand.setOnClickListener(this@MainNavigationDelegate) buttonExpand.setOnClickListener(this@MainNavigationDelegate)
@@ -93,25 +98,7 @@ class MainNavigationDelegate(
when (v.id) { when (v.id) {
R.id.button_expand -> { R.id.button_expand -> {
if (navBar is NavigationRailView) { if (navBar is NavigationRailView) {
if (navBar.isExpanded) { setNavbarIsExpanded(!navBar.isExpanded)
navBar.collapse()
navRailHeader?.run {
railFab.shrink()
buttonExpand.setImageResource(R.drawable.ic_drawer_menu)
buttonExpand.setContentDescriptionAndTooltip(R.string.expand)
val horizontalPadding = navBar.itemActiveIndicatorMarginHorizontal
root.setPadding(horizontalPadding, 0, horizontalPadding, 0)
}
} else {
navBar.expand()
navRailHeader?.run {
railFab.extend()
buttonExpand.setImageResource(R.drawable.ic_drawer_menu_open)
buttonExpand.setContentDescriptionAndTooltip(R.string.collapse)
val horizontalPadding = navBar.itemActiveIndicatorExpandedMarginHorizontal
root.setPadding(horizontalPadding, 0, horizontalPadding, 0)
}
}
} }
} }
} }
@@ -232,10 +219,13 @@ class MainNavigationDelegate(
return false return false
} }
val fragment = instantiateFragment(fragmentClass) val fragment = instantiateFragment(fragmentClass)
val args = buildBundle(1) {
putBoolean(AppRouter.KEY_IS_BOTTOMTAB, true)
}
fragment.enterTransition = MaterialFadeThrough() fragment.enterTransition = MaterialFadeThrough()
fragmentManager.beginTransaction() fragmentManager.beginTransaction()
.setReorderingAllowed(true) .setReorderingAllowed(true)
.replace(R.id.container, fragmentClass, null, TAG_PRIMARY) .replace(R.id.container, fragmentClass, args, TAG_PRIMARY)
.runOnCommit { onFragmentChanged(fragment, fromUser = true) } .runOnCommit { onFragmentChanged(fragment, fromUser = true) }
.commit() .commit()
return true return true
@@ -267,12 +257,7 @@ class MainNavigationDelegate(
} }
private fun observeSettings(lifecycleOwner: LifecycleOwner) { private fun observeSettings(lifecycleOwner: LifecycleOwner) {
settings.observe() settings.observe(AppSettings.KEY_TRACKER_ENABLED, AppSettings.KEY_SUGGESTIONS, AppSettings.KEY_NAV_LABELS)
.filter { x ->
x == AppSettings.KEY_TRACKER_ENABLED || x == AppSettings.KEY_SUGGESTIONS || x == AppSettings.KEY_NAV_LABELS
}
.onStart { emit("") }
.flowOn(Dispatchers.IO)
.onEach { .onEach {
setItemVisibility(R.id.nav_suggestions, settings.isSuggestionsEnabled) setItemVisibility(R.id.nav_suggestions, settings.isSuggestionsEnabled)
setItemVisibility(R.id.nav_feed, settings.isTrackerEnabled) setItemVisibility(R.id.nav_feed, settings.isTrackerEnabled)
@@ -298,6 +283,10 @@ class MainNavigationDelegate(
}, },
) )
} }
navRailHeader?.buttonExpand?.isVisible = value
if (!value) {
setNavbarIsExpanded(false)
}
navBar.labelVisibilityMode = if (value) { navBar.labelVisibilityMode = if (value) {
NavigationBarView.LABEL_VISIBILITY_LABELED NavigationBarView.LABEL_VISIBILITY_LABELED
} else { } else {
@@ -305,6 +294,37 @@ class MainNavigationDelegate(
} }
} }
private fun setNavbarIsExpanded(value: Boolean) {
if (navBar !is NavigationRailView) {
return
}
if (value) {
navBar.expand()
navRailHeader?.run {
root.updateLayoutParams<FrameLayout.LayoutParams> {
gravity = Gravity.TOP or Gravity.START
}
railFab.extend()
buttonExpand.setImageResource(R.drawable.ic_drawer_menu_open)
buttonExpand.setContentDescriptionAndTooltip(R.string.collapse)
val horizontalPadding = navBar.itemActiveIndicatorExpandedMarginHorizontal
root.setPadding(horizontalPadding, 0, horizontalPadding, 0)
}
} else {
navBar.collapse()
navRailHeader?.run {
root.updateLayoutParams<FrameLayout.LayoutParams> {
gravity = Gravity.TOP or Gravity.CENTER
}
railFab.shrink()
buttonExpand.setImageResource(R.drawable.ic_drawer_menu)
buttonExpand.setContentDescriptionAndTooltip(R.string.expand)
val horizontalPadding = navBar.itemActiveIndicatorMarginHorizontal
root.setPadding(horizontalPadding, 0, horizontalPadding, 0)
}
}
}
fun interface OnFragmentChangedListener { fun interface OnFragmentChangedListener {
fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) fun onFragmentChanged(fragment: Fragment, fromUser: Boolean)

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.main.ui
import android.os.Build
import androidx.activity.OnBackPressedCallback
import androidx.annotation.DeprecatedSinceApi
import com.google.android.material.search.SearchView
@DeprecatedSinceApi(Build.VERSION_CODES.TIRAMISU)
class SearchViewLegacyBackCallback(
private val searchView: SearchView
) : OnBackPressedCallback(searchView.isShowing), SearchView.TransitionListener {
override fun handleOnBackPressed() {
searchView.hide()
}
override fun onStateChanged(
searchView: SearchView,
previousState: SearchView.TransitionState,
newState: SearchView.TransitionState
) {
isEnabled = newState >= SearchView.TransitionState.SHOWING
}
}

View File

@@ -5,7 +5,7 @@ import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.picker.ui.PageImagePickActivity import org.koitharu.kotatsu.picker.ui.PageImagePickActivity
@AndroidEntryPoint @AndroidEntryPoint
@@ -17,8 +17,8 @@ class MangaPickerFragment : MangaListFragment() {
override fun onScrolledToEnd() = Unit override fun onScrolledToEnd() = Unit
override fun onItemClick(item: Manga, view: View) { override fun onItemClick(item: MangaListModel, view: View) {
(activity as PageImagePickActivity).onMangaPicked(item) (activity as PageImagePickActivity).onMangaPicked(item.manga)
} }
override fun onResume() { override fun onResume() {
@@ -26,7 +26,7 @@ class MangaPickerFragment : MangaListFragment() {
activity?.setTitle(R.string.pick_manga_page) activity?.setTitle(R.string.pick_manga_page)
} }
override fun onItemLongClick(item: Manga, view: View): Boolean = false override fun onItemLongClick(item: MangaListModel, view: View): Boolean = false
override fun onItemContextClick(item: Manga, view: View): Boolean = false override fun onItemContextClick(item: MangaListModel, view: View): Boolean = false
} }

View File

@@ -8,7 +8,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import org.koitharu.kotatsu.core.util.ext.getEnumValue import org.koitharu.kotatsu.core.util.ext.getEnumValue
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeChanges
import org.koitharu.kotatsu.core.util.ext.putAll import org.koitharu.kotatsu.core.util.ext.putAll
import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.reader.domain.TapGridArea import org.koitharu.kotatsu.reader.domain.TapGridArea
@@ -44,7 +44,7 @@ class TapGridSettings @Inject constructor(@ApplicationContext context: Context)
initPrefs(withDefaultValues = false) initPrefs(withDefaultValues = false)
} }
fun observe() = prefs.observe().flowOn(Dispatchers.IO) fun observeChanges() = prefs.observeChanges().flowOn(Dispatchers.IO)
fun getAllValues(): Map<String, *> = prefs.all fun getAllValues(): Map<String, *> = prefs.all

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