Compare commits

..

187 Commits
v9.0 ... v9.3

Author SHA1 Message Date
Koitharu
ebc17b645b Fix build 2025-10-19 16:08:37 +03:00
Koitharu
cc14e1abcf Fix crash when fast go to background 2025-10-19 15:26:25 +03:00
Koitharu
b1b474e2e7 Update readme 2025-10-19 15:08:41 +03:00
Koitharu
8ca3bece5d Fix crashes 2025-10-19 14:53:31 +03:00
Frosted
90bd9023d5 Translated using Weblate (Turkish)
Currently translated at 100.0% (876 of 876 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-10-19 14:41:29 +03:00
Максим Горпиніч
986627f24d Translated using Weblate (Ukrainian)
Currently translated at 100.0% (876 of 876 strings)

Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2025-10-19 14:41:29 +03:00
kota fujimi
cf2b8e2481 Translated using Weblate (Japanese)
Currently translated at 76.4% (669 of 875 strings)

Co-authored-by: kota fujimi <urakids@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2025-10-19 14:41:29 +03:00
Koitharu
b9435de5cd Update parsers 2025-10-19 14:33:31 +03:00
Koitharu
861c21faea Merge pull request #1673 from EmilieHascoet/Feat/auto-scroll-speed 2025-10-16 11:03:46 +03:00
Emilie Hascoët
9b4d014b21 Change valueTo from 0.95 to 0.97 in XML layout 2025-10-16 09:32:02 +02:00
Koitharu
c6da7de699 Fix backup rules 2025-10-15 15:24:02 +03:00
Koitharu
ef3aa40acc Fix some warnings 2025-10-14 16:25:28 +03:00
Koitharu
07af3ea703 Backup-restore fixes 2025-10-14 14:38:54 +03:00
Koitharu
391c8ab649 Fix sync issues 2025-10-14 14:04:17 +03:00
Koitharu
6b1885c89d Revert "Fix screen rotation causing reader to jump back to initial page"
This reverts commit aeb3732d75.
2025-10-14 13:49:02 +03:00
Koitharu
8423b48fb9 Revert "Fix page position loss when switching reader modes"
This reverts commit 5f879f6c83.
2025-10-14 13:49:02 +03:00
Koitharu
803c825d91 Fix ProgressUpdateUseCase 2025-10-14 13:46:16 +03:00
Koitharu
6a9682a077 Refactor reader sensitivity settings #1576 2025-10-14 10:23:47 +03:00
Koitharu
9197b9cc3a Merge branch 'devel' of github.com:puargs/Kotatsu into devel 2025-10-14 09:47:17 +03:00
google-labs-jules[bot]
02ea804874 Fix(reader): Fix incorrect scaling for short images in webtoon reader
When an image in the webtoon reader is shorter than the screen height, it was being incorrectly scaled, causing it to appear zoomed in or cropped.

This was caused by the `scrollTo` function in `WebtoonImageView.kt` calling `resetScaleAndCenter()` for images with a scroll range of zero. This method, from the underlying SubsamplingScaleImageView library, resets the image to a default scale instead of using the custom logic required by the reader.

The fix replaces the call to `resetScaleAndCenter()` with `scrollToInternal(0)`. This ensures that the custom scaling logic, which fits the image to the screen width, is applied consistently to all images, regardless of their height.
2025-10-14 09:45:41 +03:00
Koitharu
c424466198 Debounce Discord RPC 2025-10-14 09:44:18 +03:00
Quentinho199
18b312dde6 "fix/UI bug about finding chapter" 2025-10-14 09:44:01 +03:00
Koitharu
f78262b1a0 Udpate dependencies 2025-10-13 19:21:18 +03:00
Koitharu
c557a51c4d Fix loading local manga in some corner cases 2025-10-13 19:21:18 +03:00
kota fujimi
8995762935 Translated using Weblate (Japanese)
Currently translated at 76.0% (665 of 875 strings)

Co-authored-by: kota fujimi <urakids@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2025-10-11 15:04:54 +03:00
Milo Ivir
ed2664db78 Translated using Weblate (Croatian)
Currently translated at 100.0% (875 of 875 strings)

Translated using Weblate (Croatian)

Currently translated at 100.0% (875 of 875 strings)

Translated using Weblate (Croatian)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/hr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2025-10-11 15:04:54 +03:00
Infy's Tagalog Translations
f5a5e53b5a Translated using Weblate (Filipino)
Currently translated at 99.0% (867 of 875 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-10-11 15:04:54 +03:00
MuhamadSyabitHidayattulloh
9ef961590d Translated using Weblate (Indonesian)
Currently translated at 100.0% (875 of 875 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: MuhamadSyabitHidayattulloh <tebepc@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/id/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2025-10-11 15:04:54 +03:00
Jinzhou Huang
9b569615ee Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 75.4% (660 of 875 strings)

Co-authored-by: Jinzhou Huang <2314662431@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2025-10-11 15:04:54 +03:00
Antonio Sanchez Castellón
f48cf2efe4 Translated using Weblate (Spanish)
Currently translated at 98.5% (862 of 875 strings)

Co-authored-by: Antonio Sanchez Castellón <angelfx19@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2025-10-11 15:04:54 +03:00
Ruthwik
18094a310c Translated using Weblate (Telugu)
Currently translated at 15.4% (135 of 875 strings)

Translated using Weblate (Telugu)

Currently translated at 100.0% (9 of 9 strings)

Added translation using Weblate (Telugu)

Added translation using Weblate (Telugu)

Co-authored-by: Ruthwik <rtwk03@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/te/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/te/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2025-10-11 15:04:54 +03:00
Anon
320c49a831 Translated using Weblate (Serbian)
Currently translated at 98.5% (862 of 875 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2025-10-11 15:04:54 +03:00
return_null
2a971d5dae Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (875 of 875 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (875 of 875 strings)

Co-authored-by: return_null <demolang@dismail.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-10-11 15:04:54 +03:00
Dragibus Noir
4467e79ae6 Translated using Weblate (French)
Currently translated at 100.0% (875 of 875 strings)

Co-authored-by: Dragibus Noir <big.confetti700@aleeas.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2025-10-11 15:04:54 +03:00
Nataniel Dika Kurniawan
c68b180bf6 Translated using Weblate (Malay)
Currently translated at 100.0% (875 of 875 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (875 of 875 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Malay)

Currently translated at 53.1% (465 of 875 strings)

Co-authored-by: Nataniel Dika Kurniawan <hikawaart2@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/id/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ms/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2025-10-11 15:04:54 +03:00
copilot-swe-agent[bot]
5f879f6c83 Fix page position loss when switching reader modes
- Compare content.state with getCurrentState() to detect configuration changes vs intentional updates
- Use content.state when they match (mode switch case) to preserve explicit state updates
- Use getCurrentState() when they differ (rotation case) to restore saved position
- This ensures both screen rotation and mode switching work correctly

Co-authored-by: NathanBap <101987516+NathanBap@users.noreply.github.com>
2025-10-11 15:04:37 +03:00
copilot-swe-agent[bot]
aeb3732d75 Fix screen rotation causing reader to jump back to initial page
- Modified BaseReaderFragment to always use getCurrentState() when available
- getCurrentState() contains the most recent reading position saved in onPause/onDestroyView
- content.state may contain the initial state from when content was first loaded
- This ensures the current page position is preserved across configuration changes like screen rotation

Co-authored-by: NathanBap <101987516+NathanBap@users.noreply.github.com>
2025-10-11 15:04:37 +03:00
dragonx943
6292a0fd6b Fix margin 2025-10-11 14:46:34 +03:00
dragonx943
8985b4135d Make button in error dialog centered 2025-10-11 14:46:34 +03:00
Koitharu
f8a5397542 Update dependencies 2025-09-28 11:50:25 +03:00
Koitharu
5f51041220 Fix crash: add error handling for scrobbling info 2025-09-26 08:55:00 +03:00
Koitharu
5a14412b62 Fix appying webtoon pull gesture settings 2025-09-26 08:52:09 +03:00
Koitharu
be012f631a Update parsers 2025-09-23 18:50:44 +03:00
Koitharu
0165f43603 Fix crash 2025-09-23 18:43:20 +03:00
Koitharu
55801a1488 Update parsers 2025-09-21 13:28:38 +03:00
Koitharu
77103f016f Update parsers 2025-09-21 13:27:22 +03:00
DashZero
6b6719a259 Translated using Weblate (Thai)
Currently translated at 50.9% (446 of 875 strings)

Co-authored-by: DashZero <mee_original@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2025-09-21 13:26:53 +03:00
Joe
822642abb0 Translated using Weblate (Belarusian)
Currently translated at 99.6% (872 of 875 strings)

Co-authored-by: Joe <happenstance@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
2025-09-21 13:26:53 +03:00
தமிழ்நேரம்
260745fb95 Translated using Weblate (Tamil)
Currently translated at 100.0% (875 of 875 strings)

Co-authored-by: தமிழ்நேரம் <anishprabu.t@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ta/
Translation: Kotatsu/Strings
2025-09-21 13:26:53 +03:00
MuhamadSyabitHidayattulloh
024ec0388f Translated using Weblate (Indonesian)
Currently translated at 100.0% (875 of 875 strings)

Co-authored-by: MuhamadSyabitHidayattulloh <tebepc@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-09-21 13:26:53 +03:00
Draken
5345998eec Translated using Weblate (Vietnamese)
Currently translated at 100.0% (875 of 875 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (869 of 869 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-09-21 13:26:53 +03:00
Nicola Bortoletto
3d56190e71 Translated using Weblate (Italian)
Currently translated at 100.0% (875 of 875 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (869 of 869 strings)

Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-09-21 13:26:53 +03:00
Shams deen
954431d0a5 Added translation using Weblate (Arabic (Egyptian))
Added translation using Weblate (Arabic (Egyptian))

Co-authored-by: Shams deen <shamsdeen84@gmail.com>
2025-09-21 13:26:53 +03:00
return_null
afec63b443 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (875 of 875 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (869 of 869 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.5% (865 of 869 strings)

Co-authored-by: return_null <demolang@dismail.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-09-21 13:26:53 +03:00
Nataniel Dika Kurniawan
ac5b29c35a Translated using Weblate (Malay)
Currently translated at 51.7% (453 of 875 strings)

Translated using Weblate (Malay)

Currently translated at 51.6% (452 of 875 strings)

Translated using Weblate (Malay)

Currently translated at 49.5% (431 of 869 strings)

Translated using Weblate (Javanese)

Currently translated at 9.4% (82 of 869 strings)

Translated using Weblate (Javanese)

Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Malay)

Currently translated at 40.5% (352 of 869 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (869 of 869 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (9 of 9 strings)

Added translation using Weblate (Javanese)

Added translation using Weblate (Javanese)

Translated using Weblate (Malay)

Currently translated at 38.4% (334 of 869 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (869 of 869 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Nataniel Dika Kurniawan <hikawaart2@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/id/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/jv/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/jv/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ms/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2025-09-21 13:26:53 +03:00
Frosted
59f5578b66 Translated using Weblate (Turkish)
Currently translated at 100.0% (875 of 875 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (869 of 869 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-09-21 13:26:53 +03:00
Roel v
391dbb4237 Translated using Weblate (Gothic)
Currently translated at 2.8% (25 of 869 strings)

Translated using Weblate (Gothic)

Currently translated at 44.4% (4 of 9 strings)

Added translation using Weblate (Gothic)

Added translation using Weblate (Gothic)

Co-authored-by: Roel v <roel11112@live.nl>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/got/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/got/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2025-09-21 13:26:53 +03:00
gekka
7d4505eb78 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.5% (865 of 869 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-09-21 13:26:53 +03:00
Максим Горпиніч
e6ceb20cf7 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (875 of 875 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (869 of 869 strings)

Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2025-09-21 13:26:53 +03:00
lenn
8004f8c093 Translated using Weblate (Polish)
Currently translated at 97.8% (849 of 868 strings)

Co-authored-by: lenn <l3ennec@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2025-09-21 13:26:53 +03:00
Koitharu
61bf2abb6c Change inset handling in reader 2025-09-21 12:28:32 +03:00
Koitharu
d9612f3427 Webtoon pull gesture refactoring 2025-09-21 11:57:27 +03:00
Koitharu
435c3824f7 Remove A5 compatibility code 2025-09-20 09:56:03 +03:00
Koitharu
c846693570 Cleanup R8 rules 2025-09-20 09:37:23 +03:00
Koitharu
123937cd01 Fix SearchView closing on back pressed (close #1532, close #1487) 2025-09-20 09:31:10 +03:00
Koitharu
9f56554313 Reduce apk size 2025-09-20 07:55:27 +03:00
Koitharu
f8687bb697 Improve background WebView usage 2025-09-17 09:27:10 +03:00
Koitharu
43d3a2cc6a Fix crash on WebView.stopLoading() 2025-09-17 09:14:28 +03:00
MuhamadSyabitHidayattulloh
a95db6ed21 chore: Show description in offline mode (#1597) 2025-09-15 11:02:52 +07:00
Koitharu
fd0bb57338 Background captcha resolving 2025-09-14 20:11:55 +03:00
Koitharu
6b94bc2632 Update dependencies 2025-09-14 20:11:55 +03:00
Koitharu
c8b91599c6 Fix OkHttp initialization 2025-09-14 20:11:55 +03:00
Draken
3a8b0f9e93 Merge pull request #1586 from MuhamadSyabitHidayattulloh/feat/pull-gesture-navigate-chapter
feat: Add Pull Gesture Navigate Chapter
2025-09-13 23:23:12 +07:00
MuhamadSyabitHidayattulloh
17a0725666 feat: Realtime Favorite and Storage Badges 2025-09-13 09:27:46 +03:00
Draken
3be7848ad9 Revert distributionSha256Sum 2025-09-13 09:25:58 +03:00
dragonx943
08202c11a3 build: migrate to gradle 9 2025-09-13 09:25:58 +03:00
MuhamadSyabitHidayattulloh
5ef907d046 fix: Ui not visible if Control Panel show 2025-09-11 09:36:24 +07:00
MuhamadSyabitHidayattulloh
c3776ea3c6 feat: Add Pull Gesture Navigate Chapter 2025-09-10 14:41:28 +07:00
Koitharu
a624bffea3 Upgrade minSdk to 23 2025-09-07 10:56:40 +03:00
Koitharu
8f38b4fe30 Replace DummyParser with TestMangaRepository 2025-09-07 10:31:13 +03:00
Koitharu
71a2de5358 Update dependencies 2025-09-06 10:41:53 +03:00
Koitharu
5478f8fb59 Fix crash in ListSelectionController 2025-09-06 10:41:49 +03:00
ViAnh
5155c9a33d Reduce gaps between webtoon pages 2025-09-06 10:12:37 +03:00
puargs
1d1e49123a feat(reader): Add sensitivity setting for double-page mode
This commit introduces a new setting to control the scroll sensitivity of the double-page reader mode.

- A SeekBar has been added to the reader configuration sheet to adjust the sensitivity.
- The DoublePageSnapHelper now uses this setting to calculate the scroll distance.
- The setting is stored in AppSettings.
2025-09-04 22:40:08 -06:00
Koitharu
f7a461a9d8 Fix sync auth buttons (close #1556) 2025-08-30 09:25:55 +03:00
Aray LXa
3a02d22e02 Translated using Weblate (Persian)
Currently translated at 70.2% (610 of 868 strings)

Translated using Weblate (Persian)

Currently translated at 66.9% (581 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-30 09:24:58 +03:00
Koitharu
2b8a29e2a6 Update parsers 2025-08-30 09:24:05 +03:00
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
299 changed files with 6744 additions and 2402 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

3
.gitignore vendored
View File

@@ -6,6 +6,7 @@
/.idea/dictionaries
/.idea/modules.xml
/.idea/misc.xml
/.idea/markdown.xml
/.idea/discord.xml
/.idea/compiler.xml
/.idea/workspace.xml
@@ -26,4 +27,4 @@
.cxx
/.idea/deviceManager.xml
/.kotlin/
/.idea/AndroidProjectSystem.xml
/.idea/AndroidProjectSystem.xml

2
.idea/.gitignore generated vendored
View File

@@ -3,3 +3,5 @@
/workspace.xml
/migrations.xml
/runConfigurations.xml
/appInsightsSettings.xml
/kotlinCodeInsightSettings.xml

26
.idea/appInsightsSettings.xml generated Normal file
View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="tabSettings">
<map>
<entry key="Firebase Crashlytics">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="PLACEHOLDER" />
<option name="mobileSdkAppId" value="" />
<option name="projectId" value="" />
<option name="projectNumber" value="" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
</map>
</option>
</component>
</project>

4
.idea/gradle.xml generated
View File

@@ -6,7 +6,7 @@
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-21" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
@@ -16,4 +16,4 @@
</GradleProjectSettings>
</option>
</component>
</project>
</project>

2
.idea/vcs.xml generated
View File

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

View File

@@ -8,7 +8,7 @@
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in online content sources.**
![Downloads count](https://img.shields.io/github/downloads/KotatsuApp/Kotatsu/total?color=1976d2) ![Latest Stable version](https://img.shields.io/github/v/release/KotatsuApp/Kotatsu?color=2596be&label=latest) ![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) [![Sources count](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FKotatsuApp%2Fkotatsu-parsers%2Frefs%2Fheads%2Fmaster%2F.github%2Fsummary.yaml&query=total&label=manga%20sources&color=%23E9321C)](https://github.com/KotatsuApp/kotatsu-parsers) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF?)](https://t.me/kotatsuapp) [![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu)](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
![Downloads count](https://img.shields.io/github/downloads/KotatsuApp/Kotatsu/total?color=1976d2) ![Latest Stable version](https://img.shields.io/github/v/release/KotatsuApp/Kotatsu?color=2596be&label=latest) ![Android 6.0](https://img.shields.io/badge/android-6.0+-brightgreen) [![Sources count](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FKotatsuApp%2Fkotatsu-parsers%2Frefs%2Fheads%2Fmaster%2F.github%2Fsummary.yaml&query=total&label=manga%20sources&color=%23E9321C)](https://github.com/KotatsuApp/kotatsu-parsers) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF?)](https://t.me/kotatsuapp) [![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu)](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
### Download
@@ -112,6 +112,6 @@ You may copy, distribute and modify the software as long as you track changes/da
<div align="left">
The developers of this application do not have any affiliation with the content available in the app. It collects content from sources that are freely available through any web browser.
The developers of this application do not have any affiliation with the content available in the app and does not store or distribute any content. This application should be considered a web browser, all content that can be found using this application is freely available on the Internet. All DMCA takedown requests should be sent to the owners of the website where the content is hosted.
</div>

View File

@@ -8,19 +8,21 @@ plugins {
id 'dagger.hilt.android.plugin'
id 'androidx.room'
id 'org.jetbrains.kotlin.plugin.serialization'
// enable if needed
// id 'dev.reformator.stacktracedecoroutinator'
}
android {
compileSdk = 35
compileSdk = 36
buildToolsVersion = '35.0.0'
namespace = 'org.koitharu.kotatsu'
defaultConfig {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 35
versionCode = 1022
versionName = '9.0'
minSdk = 23
targetSdk = 36
versionCode = 1031
versionName = '9.3'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
@@ -30,6 +32,12 @@ android {
// https://issuetracker.google.com/issues/408030127
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 {
debug {
@@ -79,6 +87,7 @@ android {
'-opt-in=coil3.annotation.InternalCoilApi',
'-opt-in=kotlinx.serialization.ExperimentalSerializationApi',
'-Xjspecify-annotations=strict',
'-Xannotation-default-target=first-only',
'-Xtype-enhancement-improvements-strict-mode'
]
}
@@ -172,6 +181,7 @@ dependencies {
implementation libs.ssiv
implementation libs.disk.lru.cache
implementation libs.markwon
implementation libs.kizzyrpc
implementation libs.acra.http
implementation libs.acra.dialog
@@ -179,6 +189,7 @@ dependencies {
implementation libs.conscrypt.android
debugImplementation libs.leakcanary.android
nightlyImplementation libs.leakcanary.android
debugImplementation libs.workinspector
testImplementation libs.junit

View File

@@ -8,8 +8,7 @@
public static void checkParameterIsNotNull(...);
public static void checkNotNullParameter(...);
}
-keep public class ** extends org.koitharu.kotatsu.core.ui.BaseFragment
-keep class org.koitharu.kotatsu.core.db.entity.* { *; }
-dontwarn okhttp3.internal.platform.**
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
@@ -17,8 +16,10 @@
-dontwarn com.google.j2objc.annotations.**
-dontwarn coil3.PlatformContext
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
-keep class org.koitharu.kotatsu.settings.about.changelog.ChangelogFragment
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
-keep class org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment { *; }
-keep class org.jsoup.parser.Tag

View File

@@ -41,8 +41,8 @@ class KotatsuApp : BaseApp() {
detectNetwork()
detectDiskWrites()
detectCustomSlowCalls()
detectResourceMismatches()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) detectResourceMismatches()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) detectExplicitGc()
penaltyLog()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
@@ -56,7 +56,9 @@ class KotatsuApp : BaseApp() {
detectLeakedSqlLiteObjects()
detectLeakedClosableObjects()
detectLeakedRegistrationObjects()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectContentUriWithoutPermission()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
detectContentUriWithoutPermission()
}
detectFileUriExposure()
penaltyLog()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {

View File

@@ -0,0 +1,57 @@
package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.model.TestMangaSource
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet
/*
This class is for parser development and testing purposes
You can open it in the app via Settings -> Debug
*/
class TestMangaRepository(
@Suppress("unused") private val loaderContext: MangaLoaderContext,
cache: MemoryContentCache
) : CachingMangaRepository(cache) {
override val source = TestMangaSource
override val sortOrders: Set<SortOrder> = EnumSet.allOf(SortOrder::class.java)
override var defaultSortOrder: SortOrder
get() = sortOrders.first()
set(value) = Unit
override val filterCapabilities = MangaListFilterCapabilities()
override suspend fun getFilterOptions() = MangaListFilterOptions()
override suspend fun getList(
offset: Int,
order: SortOrder?,
filter: MangaListFilter?
): List<Manga> = TODO("Get manga list by filter")
override suspend fun getDetailsImpl(
manga: Manga
): Manga = TODO("Fetch manga details")
override suspend fun getPagesImpl(
chapter: MangaChapter
): List<MangaPage> = TODO("Get pages for specific chapter")
override suspend fun getPageUrl(
page: MangaPage
): String = TODO("Return direct url of page image or page.url if it is already a direct url")
override suspend fun getRelatedMangaImpl(
seed: Manga
): List<Manga> = TODO("Get list of related manga. This method is optional and parser library has a default implementation")
}

View File

@@ -0,0 +1,72 @@
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.model.TestMangaSource
import org.koitharu.kotatsu.core.nav.router
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
}
KEY_TEST_PARSER -> {
router.openList(TestMangaSource, null, null)
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"
const val KEY_TEST_PARSER = "test_parser"
}
}

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,23 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
android:key="leak_canary"
android:persistent="false"
android:title="LeakCanary" />
<Preference
android:key="work_inspector"
android:persistent="false"
android:title="@string/wi_lib_name" />
<Preference
android:key="test_parser"
android:persistent="false"
android:title="@string/test_parser"
app:allowDividerAbove="true" />
</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

@@ -51,9 +51,11 @@
android:backupAgent="org.koitharu.kotatsu.backups.domain.AppBackupAgent"
android:dataExtractionRules="@xml/backup_rules"
android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled"
android:extractNativeLibs="true"
android:fullBackupContent="@xml/backup_content"
android:fullBackupOnly="true"
android:hasFragileUserData="true"
android:restoreAnyVersion="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="true"
@@ -287,6 +289,9 @@
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<activity
android:name="org.koitharu.kotatsu.scrobbling.discord.ui.DiscordAuthActivity"
android:label="@string/discord" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
@@ -404,6 +409,13 @@
tools:node="remove" />
</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
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider"
android:exported="true"

View File

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

View File

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

View File

@@ -36,15 +36,14 @@ class AppBackupAgent : BackupAgent() {
override fun onFullBackup(data: FullBackupDataOutput) {
super.onFullBackup(data)
val file =
createBackupFile(
this,
BackupRepository(
MangaDatabase(context = applicationContext),
AppSettings(applicationContext),
TapGridSettings(applicationContext),
),
)
val file = createBackupFile(
this,
BackupRepository(
MangaDatabase(context = applicationContext),
AppSettings(applicationContext),
TapGridSettings(applicationContext),
),
)
try {
fullBackupFile(file, data)
} finally {
@@ -90,8 +89,12 @@ class AppBackupAgent : BackupAgent() {
@VisibleForTesting
fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) {
ZipInputStream(ByteStreams.limit(FileInputStream(fd), size)).use { input ->
val sections = EnumSet.allOf(BackupSection::class.java)
// managed externally
sections.remove(BackupSection.SETTINGS)
sections.remove(BackupSection.SETTINGS_READER_GRID)
runBlocking {
repository.restoreBackup(input, EnumSet.allOf(BackupSection::class.java), null)
repository.restoreBackup(input, sections, null)
}
}
}

View File

@@ -21,7 +21,7 @@ enum class BackupSection(
fun of(entry: ZipEntry): BackupSection? {
val name = entry.name.lowercase(Locale.ROOT)
return entries.first { x -> x.entryName == name }
return entries.find { x -> x.entryName == name }
}
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.backups.ui
import android.content.Context
import android.net.Uri
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
@@ -27,24 +28,13 @@ abstract class BaseBackupRestoreService : CoroutineIntentService() {
override fun onCreate() {
super.onCreate()
notificationManager = NotificationManagerCompat.from(applicationContext)
createNotificationChannel()
createNotificationChannel(this)
}
override fun IntentJobContext.onError(error: Throwable) {
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(
fileUri: Uri?,
result: CompositeResult,
@@ -128,8 +118,19 @@ abstract class BaseBackupRestoreService : CoroutineIntentService() {
.setBigContentTitle(title),
)
protected companion object {
companion object {
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
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 org.koitharu.kotatsu.R
import org.koitharu.kotatsu.backups.data.BackupRepository
import org.koitharu.kotatsu.backups.domain.BackupUtils
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.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import java.util.zip.ZipOutputStream
import javax.inject.Inject
@@ -40,7 +49,7 @@ class PeriodicalBackupService : CoroutineIntentService() {
}
externalBackupStorage.put(output)
externalBackupStorage.trim(settings.periodicalBackupMaxCount)
if (settings.isBackupTelegramUploadEnabled) {
if (settings.isBackupTelegramUploadEnabled && telegramBackupUploader.isAvailable) {
telegramBackupUploader.uploadBackup(output)
}
} 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.preference.EditTextPreference
import androidx.preference.Preference
import androidx.preference.PreferenceCategory
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
@@ -37,6 +38,7 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_backup_periodic)
findPreference<PreferenceCategory>(AppSettings.KEY_BACKUP_TG)?.isVisible = viewModel.isTelegramAvailable
findPreference<EditTextPreference>(AppSettings.KEY_BACKUP_TG_CHAT)?.summaryProvider =
EditTextFallbackSummaryProvider(R.string.telegram_chat_id_summary)
}
@@ -84,6 +86,11 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
"" -> null
else -> path
}
preference.icon = if (path == null) {
getWarningIcon()
} else {
null
}
}
private fun bindLastBackupInfo(lastBackupDate: Date?) {

View File

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

View File

@@ -30,10 +30,13 @@ class TelegramBackupUploader @Inject constructor(
private val botToken = context.getString(R.string.tg_backup_bot_token)
val isAvailable: Boolean
get() = botToken.isNotEmpty()
suspend fun uploadBackup(file: File) {
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
val multipartBody = MultipartBody.Builder()
.setType(MultipartBody.Companion.FORM)
.setType(MultipartBody.FORM)
.addFormDataPart("chat_id", requireChatId())
.addFormDataPart("document", file.name, requestBody)
.build()

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.browser
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.os.Looper
import android.webkit.WebResourceRequest
@@ -15,7 +16,7 @@ import java.io.ByteArrayInputStream
open class BrowserClient(
private val callback: BrowserCallback,
private val adBlock: AdBlock,
private val adBlock: AdBlock?,
) : WebViewClient() {
/**
@@ -47,7 +48,7 @@ open class BrowserClient(
override fun shouldInterceptRequest(
view: WebView?,
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)
} else {
emptyResponse()
@@ -57,15 +58,17 @@ open class BrowserClient(
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?
): WebResourceResponse? = if (request == null || adBlock.shouldLoadUrl(request.url.toString(), view?.getUrlSafe())) {
super.shouldInterceptRequest(view, request)
} else {
emptyResponse()
}
): WebResourceResponse? =
if (request == null || adBlock?.shouldLoadUrl(request.url.toString(), view?.getUrlSafe()) ?: true) {
super.shouldInterceptRequest(view, request)
} else {
emptyResponse()
}
private fun emptyResponse(): WebResourceResponse =
WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(byteArrayOf()))
@SuppressLint("WrongThread")
@AnyThread
private fun WebView.getUrlSafe(): String? = if (Looper.myLooper() == Looper.getMainLooper()) {
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.NetworkState
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.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
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.isLowRamDevice
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageFetcher
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.FaviconCache
import org.koitharu.kotatsu.local.data.LocalStorageCache
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.main.domain.CoverRestoreInterceptor
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
@@ -101,7 +104,7 @@ interface AppModule {
fun provideCoil(
@LocalizedAppContext context: Context,
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
mangaRepositoryFactory: MangaRepository.Factory,
faviconFetcherFactory: FaviconFetcher.Factory,
imageProxyInterceptor: ImageProxyInterceptor,
pageFetcherFactory: MangaPageFetcher.Factory,
coverRestoreInterceptor: CoverRestoreInterceptor,
@@ -138,7 +141,7 @@ interface AppModule {
add(SvgDecoder.Factory())
add(CbzFetcher.Factory())
add(AvifImageDecoder.Factory())
add(FaviconFetcher.Factory(mangaRepositoryFactory))
add(faviconFetcherFactory)
add(MangaPageKeyer())
add(pageFetcherFactory)
add(imageProxyInterceptor)
@@ -195,5 +198,29 @@ interface AppModule {
fun provideWorkManager(
@ApplicationContext context: 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

@@ -8,11 +8,11 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.hilt.work.HiltWorkerFactory
import androidx.room.InvalidationTracker
import androidx.work.Configuration
import androidx.work.WorkManager
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import okhttp3.internal.platform.PlatformRegistry
import org.acra.ACRA
import org.acra.ReportField
import org.acra.config.dialog
@@ -27,7 +27,6 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.os.AppValidator
import org.koitharu.kotatsu.core.os.RomCompat
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
@@ -62,9 +61,6 @@ open class BaseApp : Application(), Configuration.Provider {
@Inject
lateinit var workScheduleManager: WorkScheduleManager
@Inject
lateinit var workManagerProvider: Provider<WorkManager>
@Inject
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>
@@ -79,6 +75,7 @@ open class BaseApp : Application(), Configuration.Provider {
override fun onCreate() {
super.onCreate()
PlatformRegistry.applicationContext = this // TODO replace with OkHttp.initialize
if (ACRA.isACRASenderServiceProcess()) {
return
}
@@ -97,7 +94,6 @@ open class BaseApp : Application(), Configuration.Provider {
localStorageChanges.collect(localMangaIndexProvider.get())
}
workScheduleManager.init()
WorkServiceStopHelper(workManagerProvider).setup()
}
override fun attachBaseContext(base: Context) {

View File

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

View File

@@ -5,16 +5,15 @@ import android.app.Notification
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.provider.Settings
import androidx.annotation.CheckResult
import androidx.annotation.RequiresPermission
import androidx.collection.MutableScatterMap
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.coroutineScope
import coil3.EventListener
@@ -26,6 +25,7 @@ import coil3.request.allowConversionToBitmap
import coil3.request.allowHardware
import coil3.request.lifecycle
import coil3.size.Scale
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -44,6 +44,7 @@ import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.network.webview.WebViewExecutor
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
@@ -55,6 +56,7 @@ import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import org.koitharu.kotatsu.parsers.util.mapToArray
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
import javax.inject.Provider
@@ -65,27 +67,13 @@ class CaptchaHandler @Inject constructor(
@LocalizedAppContext private val context: Context,
private val databaseProvider: Provider<MangaDatabase>,
private val coilProvider: Provider<ImageLoader>,
private val webViewExecutor: WebViewExecutor,
) : EventListener() {
private val exceptionMap = MutableScatterMap<MangaSource, CloudFlareProtectedException>()
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,
)
}
@CheckResult
suspend fun handle(exception: CloudFlareException): Boolean = handleException(exception.source, exception, true)
suspend fun discard(source: MangaSource) {
@@ -95,10 +83,18 @@ class CaptchaHandler @Inject constructor(
override fun onError(request: ImageRequest, result: ErrorResult) {
super.onError(request, result)
val e = result.throwable
if (e is CloudFlareException && request.extras[ignoreCaptchaKey] != true) {
if (e is CloudFlareException) {
val scope = request.lifecycle?.coroutineScope ?: processLifecycleScope
scope.launch {
handleException(e.source, e, true)
if (
handleException(
source = e.source,
exception = e,
notify = request.extras[suppressCaptchaKey] != true,
)
) {
coilProvider.get().enqueue(request) // TODO check if ok
}
}
}
}
@@ -106,11 +102,14 @@ class CaptchaHandler @Inject constructor(
private suspend fun handleException(
source: MangaSource,
exception: CloudFlareException?,
notify: Boolean
notify: Boolean,
): Boolean = withContext(Dispatchers.Default) {
if (source == UnknownMangaSource) {
return@withContext false
}
if (exception != null && webViewExecutor.tryResolveCaptcha(exception, RESOLVE_TIMEOUT)) {
return@withContext true
}
mutex.withLock {
var removedException: CloudFlareProtectedException? = null
if (exception is CloudFlareProtectedException) {
@@ -121,21 +120,21 @@ class CaptchaHandler @Inject constructor(
val dao = databaseProvider.get().getSourcesDao()
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)) {
val exceptions = dao.findAllCaptchaRequired().mapNotNull {
it.source.toMangaSourceOrNull()
}.filterNot {
SourceSettings(context, it).isCaptchaNotificationsDisabled
}.mapNotNull {
exceptionMap[it]
}
if (removedException != null) {
NotificationManagerCompat.from(context).cancel(TAG, removedException.source.hashCode())
}
notify(exceptions)
}
}
true
false
}
@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
@@ -169,6 +168,15 @@ class CaptchaHandler @Inject constructor(
.setOnlyAlertOnce(true)
.setSmallIcon(R.drawable.ic_bot)
.setGroup(GROUP_CAPTCHA)
.setContentIntent(
PendingIntentCompat.getActivities(
context, GROUP_NOTIFICATION_ID,
exceptions.mapToArray { e ->
AppRouter.cloudFlareResolveIntent(context, e)
},
0, false,
),
)
.setContentText(
context.getString(
R.string.captcha_required_summary, context.getString(R.string.app_name),
@@ -189,7 +197,6 @@ class CaptchaHandler @Inject constructor(
private suspend fun buildNotification(exception: CloudFlareProtectedException): Notification {
val intent = AppRouter.cloudFlareResolveIntent(context, exception)
.setData(exception.url.toUri())
val discardIntent = Intent(ACTION_DISCARD)
.putExtra(AppRouter.KEY_SOURCE, exception.source.name)
.setData("source://${exception.source.name}".toUri())
@@ -242,6 +249,7 @@ class CaptchaHandler @Inject constructor(
.data(source.faviconUri())
.allowHardware(false)
.allowConversionToBitmap(true)
.suppressCaptchaErrors()
.mangaSourceExtra(source)
.size(context.resources.getNotificationIconSize())
.scale(Scale.FILL)
@@ -251,13 +259,27 @@ class CaptchaHandler @Inject constructor(
it.printStackTraceDebug()
}.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 {
fun ImageRequest.Builder.ignoreCaptchaErrors() = apply {
extras[ignoreCaptchaKey] = true
fun ImageRequest.Builder.suppressCaptchaErrors() = apply {
extras[suppressCaptchaKey] = true
}
val ignoreCaptchaKey = Extras.Key(false)
private val suppressCaptchaKey = Extras.Key(false)
private const val CHANNEL_ID = "captcha"
private const val TAG = CHANNEL_ID
@@ -265,5 +287,6 @@ class CaptchaHandler @Inject constructor(
private const val GROUP_NOTIFICATION_ID = 34
private const val SETTINGS_ACTION_CODE = 3
private const val ACTION_DISCARD = "org.koitharu.kotatsu.CAPTCHA_DISCARD"
private const val RESOLVE_TIMEOUT = 20_000L
}
}

View File

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

View File

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

View File

@@ -28,11 +28,15 @@ data object UnknownMangaSource : MangaSource {
override val name = "UNKNOWN"
}
data object TestMangaSource : MangaSource {
override val name = "TEST"
}
fun MangaSource(name: String?): MangaSource {
when (name ?: return UnknownMangaSource) {
UnknownMangaSource.name -> return UnknownMangaSource
LocalMangaSource.name -> return LocalMangaSource
TestMangaSource.name -> return TestMangaSource
}
if (name.startsWith("content:")) {
val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownMangaSource
@@ -92,6 +96,7 @@ fun MangaSource.getSummary(context: Context): String? = when (val source = unwra
fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()) {
is MangaParserSource -> source.title
LocalMangaSource -> context.getString(R.string.local_storage)
TestMangaSource -> context.getString(R.string.test_parser)
is ExternalMangaSource -> source.resolveName(context)
else -> context.getString(R.string.unknown)
}

View File

@@ -281,6 +281,10 @@ class AppRouter private constructor(
startActivity(sourcesSettingsIntent(contextOrNull() ?: return))
}
fun openDiscordSettings() {
startActivity(discordSettingsIntent(contextOrNull() ?: return))
}
fun openReaderTapGridSettings() = startActivity(ReaderTapGridConfigActivity::class.java)
fun openScrobblerSettings(scrobbler: ScrobblerService) {
@@ -571,7 +575,7 @@ class AppRouter private constructor(
/** Public utils **/
fun isFilterSupported(): Boolean = when {
fragment != null -> fragment.activity is FilterCoordinator.Owner
fragment != null -> FilterCoordinator.find(fragment) != null
activity != null -> activity is FilterCoordinator.Owner
else -> false
}
@@ -741,6 +745,14 @@ class AppRouter private constructor(
Intent(context, SettingsActivity::class.java)
.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) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_PROXY)
@@ -786,7 +798,7 @@ class AppRouter private constructor(
else -> true
}
fun shortMangaUrl(mangaId: Long) = Uri.Builder()
fun shortMangaUrl(mangaId: Long): Uri = Uri.Builder()
.scheme("kotatsu")
.path("manga")
.appendQueryParameter("id", mangaId.toString())
@@ -800,6 +812,7 @@ class AppRouter private constructor(
const val KEY_FILTER = "filter"
const val KEY_ID = "id"
const val KEY_INDEX = "index"
const val KEY_IS_BOTTOMTAB = "is_btab"
const val KEY_KIND = "kind"
const val KEY_LIST_SECTION = "list_section"
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_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
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_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 ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS"

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.core.network.webview
import android.graphics.Bitmap
import android.webkit.WebView
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import kotlin.coroutines.Continuation
class CaptchaContinuationClient(
private val cookieJar: MutableCookieJar,
private val targetUrl: String,
continuation: Continuation<Unit>,
) : ContinuationResumeWebViewClient(continuation) {
private val oldClearance = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl)
override fun onPageFinished(view: WebView?, url: String?) = Unit
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
checkClearance(view)
}
private fun checkClearance(view: WebView?) {
val clearance = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl)
if (clearance != null && clearance != oldClearance) {
resumeContinuation(view)
}
}
}

View File

@@ -2,15 +2,22 @@ package org.koitharu.kotatsu.core.network.webview
import android.webkit.WebView
import android.webkit.WebViewClient
import kotlinx.coroutines.CancellableContinuation
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
class ContinuationResumeWebViewClient(
open class ContinuationResumeWebViewClient(
private val continuation: Continuation<Unit>,
) : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
view?.webViewClient = WebViewClient() // reset to default
continuation.resume(Unit)
resumeContinuation(view)
}
protected fun resumeContinuation(view: WebView?) {
if (continuation !is CancellableContinuation || continuation.isActive) {
view?.webViewClient = WebViewClient() // reset to default
continuation.resume(Unit)
}
}
}

View File

@@ -0,0 +1,134 @@
package org.koitharu.kotatsu.core.network.webview
import android.content.Context
import android.util.AndroidRuntimeException
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.annotation.MainThread
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.koitharu.kotatsu.core.exceptions.CloudFlareException
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.lang.ref.WeakReference
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Singleton
class WebViewExecutor @Inject constructor(
@ApplicationContext private val context: Context,
private val proxyProvider: ProxyProvider,
private val cookieJar: MutableCookieJar,
private val mangaRepositoryFactoryProvider: Provider<MangaRepository.Factory>,
) {
private var webViewCached: WeakReference<WebView>? = null
private val mutex = Mutex()
val defaultUserAgent: String? by lazy {
try {
WebSettings.getDefaultUserAgent(context)
} catch (e: AndroidRuntimeException) {
e.printStackTraceDebug()
// Probably WebView is not available
null
}
}
suspend fun evaluateJs(baseUrl: String?, script: String): String? = mutex.withLock {
withContext(Dispatchers.Main.immediate) {
val webView = obtainWebView()
try {
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" })
}
}
} finally {
webView.reset()
}
}
}
suspend fun tryResolveCaptcha(exception: CloudFlareException, timeout: Long): Boolean = mutex.withLock {
runCatchingCancellable {
withContext(Dispatchers.Main.immediate) {
val webView = obtainWebView()
try {
exception.source.getUserAgent()?.let {
webView.settings.userAgentString = it
}
withTimeout(timeout) {
suspendCancellableCoroutine { cont ->
webView.webViewClient = CaptchaContinuationClient(
cookieJar = cookieJar,
targetUrl = exception.url,
continuation = cont,
)
webView.loadUrl(exception.url)
}
}
} finally {
webView.reset()
}
}
}.onFailure { e ->
exception.addSuppressed(e)
e.printStackTraceDebug()
}.isSuccess
}
private suspend fun obtainWebView(): WebView {
webViewCached?.get()?.let {
return it
}
return withContext(Dispatchers.Main.immediate) {
webViewCached?.get()?.let {
return@withContext it
}
WebView(context).also {
it.configureForParser(null)
webViewCached = WeakReference(it)
proxyProvider.applyWebViewConfig()
it.onResume()
it.resumeTimers()
}
}
}
private fun MangaSource.getUserAgent(): String? {
val repository = mangaRepositoryFactoryProvider.get().create(this) as? ParserMangaRepository
return repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
}
@MainThread
private fun WebView.reset() {
stopLoading()
webViewClient = WebViewClient()
settings.userAgentString = defaultUserAgent
loadDataWithBaseURL(null, " ", "text/html", null, null)
clearHistory()
}
}

View File

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

View File

@@ -80,12 +80,7 @@ class NetworkState(
if (settings.isOfflineCheckDisabled) {
return true
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
activeNetwork?.let { isOnline(it) } == true
} else {
@Suppress("DEPRECATION")
activeNetworkInfo?.isConnected == true
}
return activeNetwork?.let { isOnline(it) } == true
}
private fun ConnectivityManager.isOnline(network: Network): Boolean {

View File

@@ -13,7 +13,6 @@ import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
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
class OpenDocumentTreeHelper(
@@ -28,38 +27,42 @@ class OpenDocumentTreeHelper(
callback,
)
private val pickFileTreeLauncherQ = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
activityResultCaller.registerForActivityResult(OpenDocumentTreeContractQ(flags), callback)
private val pickFileTreeLauncherPrimaryStorage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
activityResultCaller.registerForActivityResult(OpenDocumentTreeContractPrimaryStorage(flags), callback)
} else {
null
}
private val pickFileTreeLauncherLegacy = activityResultCaller.registerForActivityResult(
contract = OpenDocumentTreeContractLegacy(flags),
private val pickFileTreeLauncherDefault = activityResultCaller.registerForActivityResult(
contract = OpenDocumentTreeContractDefault(flags),
callback = callback,
)
override fun launch(input: Uri?, options: ActivityOptionsCompat?) {
if (pickFileTreeLauncherQ == null) {
pickFileTreeLauncherLegacy.launch(input, options)
return
}
try {
pickFileTreeLauncherQ.launch(input, options)
pickFileTreeLauncherDefault.launch(input, options)
} catch (e: Exception) {
e.printStackTraceDebug()
pickFileTreeLauncherLegacy.launch(input, options)
if (pickFileTreeLauncherPrimaryStorage != null) {
try {
pickFileTreeLauncherPrimaryStorage.launch(input, options)
} catch (e2: Exception) {
e.addSuppressed(e2)
throw e
}
} else {
throw e
}
}
}
override fun unregister() {
pickFileTreeLauncherQ?.unregister()
pickFileTreeLauncherLegacy.unregister()
pickFileTreeLauncherPrimaryStorage?.unregister()
pickFileTreeLauncherDefault.unregister()
}
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,
) : ActivityResultContracts.OpenDocumentTree() {
@@ -71,9 +74,9 @@ class OpenDocumentTreeHelper(
}
@RequiresApi(Build.VERSION_CODES.Q)
private class OpenDocumentTreeContractQ(
private class OpenDocumentTreeContractPrimaryStorage(
private val flags: Int,
) : OpenDocumentTreeContractLegacy(flags) {
) : OpenDocumentTreeContractDefault(flags) {
override fun createIntent(context: Context, input: Uri?): Intent {
val intent = (context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager)

View File

@@ -1,42 +0,0 @@
package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.AbstractMangaParser
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
import java.util.EnumSet
/**
* This parser is just for parser development, it should not be used in releases
*/
class DummyParser(context: MangaLoaderContext) : AbstractMangaParser(context, MangaParserSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("localhost")
override val availableSortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)
override val searchQueryCapabilities: MangaSearchQueryCapabilities
get() = MangaSearchQueryCapabilities()
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
override suspend fun getList(query: MangaSearchQuery): List<Manga> = stub(null)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
private fun stub(manga: Manga?): Nothing {
throw UnsupportedSourceException("Usage of Dummy parser", manga)
}
}

View File

@@ -11,7 +11,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
open class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)

View File

@@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
import org.koitharu.kotatsu.core.db.TABLE_PREFERENCES
import org.koitharu.kotatsu.core.db.entity.ContentRating
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
@@ -41,7 +43,7 @@ class MangaDataRepository @Inject constructor(
suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) {
db.withTransaction {
storeManga(manga)
storeManga(manga, replaceExisting = false)
val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id)
db.getPreferencesDao().upsert(entity.copy(mode = mode.id))
}
@@ -49,7 +51,7 @@ class MangaDataRepository @Inject constructor(
suspend fun saveColorFilter(manga: Manga, colorFilter: ReaderColorFilter?) {
db.withTransaction {
storeManga(manga)
storeManga(manga, replaceExisting = false)
val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id)
db.getPreferencesDao().upsert(
entity.copy(
@@ -87,10 +89,11 @@ class MangaDataRepository @Inject constructor(
return map
}
suspend fun setOverride(mangaId: Long, override: MangaOverride?) {
suspend fun setOverride(manga: Manga, override: MangaOverride?) {
db.withTransaction {
storeManga(manga, replaceExisting = false)
val dao = db.getPreferencesDao()
val entity = dao.find(mangaId) ?: newEntity(mangaId)
val entity = dao.find(manga.id) ?: newEntity(manga.id)
dao.upsert(
entity.copy(
titleOverride = override?.title?.nullIfEmpty(),
@@ -127,7 +130,10 @@ class MangaDataRepository @Inject constructor(
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 {
// avoid storing local manga if remote one is already stored
val existing = if (manga.isLocal) {
@@ -185,7 +191,12 @@ class MangaDataRepository @Inject constructor(
emitInitialState = emitInitialState,
)
private suspend fun Manga.withCachedChaptersIfNeeded(flag: Boolean): Manga = if (flag && chapters.isNullOrEmpty()) {
fun observeFavoritesTrigger(emitInitialState: Boolean) = db.invalidationTracker.createFlow(
tables = arrayOf(TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES),
emitInitialState = emitInitialState,
)
private suspend fun Manga.withCachedChaptersIfNeeded(flag: Boolean): Manga = if (flag && !isLocal && chapters.isNullOrEmpty()) {
val cachedChapters = db.getChaptersDao().findAll(id)
if (cachedChapters.isEmpty()) {
this

View File

@@ -3,15 +3,8 @@ package org.koitharu.kotatsu.core.parser
import android.annotation.SuppressLint
import android.content.Context
import android.util.Base64
import android.webkit.WebView
import androidx.annotation.MainThread
import androidx.core.os.LocaleListCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
@@ -22,11 +15,8 @@ import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
import org.koitharu.kotatsu.core.image.BitmapDecoderCompat
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.network.webview.ContinuationResumeWebViewClient
import org.koitharu.kotatsu.core.network.webview.WebViewExecutor
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.toMimeType
import org.koitharu.kotatsu.core.util.ext.use
@@ -37,25 +27,19 @@ import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.UserAgents
import org.koitharu.kotatsu.parsers.util.map
import java.lang.ref.WeakReference
import java.util.Locale
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Singleton
class MangaLoaderContextImpl @Inject constructor(
@MangaHttpClient override val httpClient: OkHttpClient,
override val cookieJar: MutableCookieJar,
@ApplicationContext private val androidContext: Context,
private val webViewExecutor: WebViewExecutor,
) : MangaLoaderContext() {
private var webViewCached: WeakReference<WebView>? = null
private val webViewUserAgent by lazy { obtainWebViewUserAgent() }
private val jsMutex = Mutex()
private val jsTimeout = TimeUnit.SECONDS.toMillis(4)
@Deprecated("Provide a base url")
@@ -63,25 +47,10 @@ class MangaLoaderContextImpl @Inject constructor(
override suspend fun evaluateJs(script: String): String? = evaluateJs("", script)
override suspend fun evaluateJs(baseUrl: String, script: String): String? = withTimeout(jsTimeout) {
jsMutex.withLock {
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" })
}
}
}
}
webViewExecutor.evaluateJs(baseUrl, script)
}
override fun getDefaultUserAgent(): String = webViewUserAgent
override fun getDefaultUserAgent(): String = webViewExecutor.defaultUserAgent ?: UserAgents.FIREFOX_MOBILE
override fun getConfig(source: MangaSource): MangaSourceConfig {
return SourceSettings(androidContext, source)
@@ -118,28 +87,4 @@ class MangaLoaderContextImpl @Inject constructor(
}
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 {
val mainDispatcher = Dispatchers.Main.immediate
return if (!mainDispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
obtainWebViewUserAgentImpl()
} else {
runBlocking(mainDispatcher) {
obtainWebViewUserAgentImpl()
}
}
}
@MainThread
private fun obtainWebViewUserAgentImpl() = runCatching {
obtainWebView().settings.userAgentString.sanitizeHeaderValue()
}.onFailure { e ->
e.printStackTraceDebug()
}.getOrDefault(UserAgents.FIREFOX_MOBILE)
}

View File

@@ -1,12 +0,0 @@
package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.model.MangaParserSource
fun MangaParser(source: MangaParserSource, loaderContext: MangaLoaderContext): MangaParser {
return when (source) {
MangaParserSource.DUMMY -> DummyParser(loaderContext)
else -> loaderContext.newParserInstance(source)
}
}

View File

@@ -7,6 +7,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.model.TestMangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
@@ -85,11 +86,16 @@ interface MangaRepository {
private fun createRepository(source: MangaSource): MangaRepository? = when (source) {
is MangaParserSource -> ParserMangaRepository(
parser = MangaParser(source, loaderContext),
parser = loaderContext.newParserInstance(source),
cache = contentCache,
mirrorSwitcher = mirrorSwitcher,
)
TestMangaSource -> TestMangaRepository(
loaderContext = loaderContext,
cache = contentCache,
)
is ExternalMangaSource -> if (source.isAvailable(context)) {
ExternalMangaRepository(
contentResolver = context.contentResolver,

View File

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

View File

@@ -10,15 +10,21 @@ import coil3.ColorImage
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.size.pxOrElse
import coil3.toAndroidUri
import coil3.toBitmap
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.runInterruptible
import okio.FileSystem
import okio.IOException
import okio.Path.Companion.toOkioPath
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource
@@ -26,9 +32,16 @@ import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.util.MimeTypes
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 kotlin.coroutines.coroutineContext
import org.koitharu.kotatsu.local.data.LocalStorageCache
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File
import javax.inject.Inject
import coil3.Uri as CoilUri
class FaviconFetcher(
@@ -36,6 +49,7 @@ class FaviconFetcher(
private val options: Options,
private val imageLoader: ImageLoader,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val localStorageCache: LocalStorageCache,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
@@ -61,15 +75,29 @@ class FaviconFetcher(
options.size.width.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 lastError: Exception? = null
while (favicons.isNotEmpty()) {
coroutineContext.ensureActive()
currentCoroutineContext().ensureActive()
val icon = favicons.find(sizePx) ?: throwNSEE(lastError)
try {
val result = imageLoader.fetch(icon.url, options)
if (result != null) {
return result
return if (options.diskCachePolicy.writeEnabled) {
writeToCache(cacheKey, result)
} else {
result
}
} else {
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,
@FaviconCache private val faviconCache: LocalStorageCache,
) : Fetcher.Factory<CoilUri> {
override fun create(
@@ -106,7 +165,7 @@ class FaviconFetcher(
options: Options,
imageLoader: ImageLoader
): Fetcher? = if (data.scheme == URI_SCHEME_FAVICON) {
FaviconFetcher(data.toAndroidUri(), options, imageLoader, mangaRepositoryFactory)
FaviconFetcher(data.toAndroidUri(), options, imageLoader, mangaRepositoryFactory, faviconCache)
} else {
null
}

View File

@@ -15,12 +15,17 @@ import androidx.core.os.LocaleListCompat
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
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.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.core.util.ext.connectivityManager
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.putEnumValue
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
@@ -82,6 +87,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isNavBarPinned: Boolean
get() = prefs.getBoolean(KEY_NAV_PINNED, false)
val isMainFabEnabled: Boolean
get() = prefs.getBoolean(KEY_MAIN_FAB, true)
var gridSize: Int
get() = prefs.getInt(KEY_GRID_SIZE, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
@@ -130,6 +138,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
@get:FloatRange(0.0, 1.0)
var readerDoublePagesSensitivity: Float
get() = prefs.getFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, 0.5f)
set(@FloatRange(0.0, 1.0) value) = prefs.edit { putFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, value) }
val readerScreenOrientation: Int
get() = prefs.getString(KEY_READER_ORIENTATION, null)?.toIntOrNull()
?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
@@ -480,6 +493,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_WEBTOON_GAPS, false)
set(value) = prefs.edit { putBoolean(KEY_WEBTOON_GAPS, value) }
var isWebtoonPullGestureEnabled: Boolean
get() = prefs.getBoolean(KEY_WEBTOON_PULL_GESTURE, false)
set(value) = prefs.edit { putBoolean(KEY_WEBTOON_PULL_GESTURE, value) }
@get:FloatRange(from = 0.0, to = 0.5)
val defaultWebtoonZoomOut: Float
get() = prefs.getInt(KEY_WEBTOON_ZOOM_OUT, 0).coerceIn(0, 50) / 100f
@@ -494,6 +511,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
get() {
if (isBackgroundNetworkRestricted()) {
@@ -509,6 +530,16 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val is32BitColorsEnabled: Boolean
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
get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false)
@@ -598,7 +629,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
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
@@ -642,6 +678,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_REMOTE_SOURCES = "remote_sources"
const val KEY_LOCAL_STORAGE = "local_storage"
const val KEY_READER_DOUBLE_PAGES = "reader_double_pages"
const val KEY_READER_DOUBLE_PAGES_SENSITIVITY = "reader_double_pages_sensitivity"
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
const val KEY_READER_NAVIGATION_INVERTED = "reader_navigation_inverted"
@@ -721,6 +758,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_WEBTOON_GAPS = "webtoon_gaps"
const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
const val KEY_WEBTOON_ZOOM_OUT = "webtoon_zoom_out"
const val KEY_WEBTOON_PULL_GESTURE = "webtoon_pull_gesture"
const val KEY_PREFETCH_CONTENT = "prefetch_content"
const val KEY_APP_LOCALE = "app_locale"
const val KEY_SOURCES_GRID = "sources_grid"
@@ -728,6 +766,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_TIPS_CLOSED = "tips_closed"
const val KEY_SSL_BYPASS = "ssl_bypass"
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_PROXY = "proxy"
const val KEY_PROXY_TYPE = "proxy_type_2"
@@ -743,6 +782,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_NAV_MAIN = "nav_main"
const val KEY_NAV_LABELS = "nav_labels"
const val KEY_NAV_PINNED = "nav_pinned"
const val KEY_MAIN_FAB = "main_fab"
const val KEY_32BIT_COLOR = "enhanced_colors"
const val KEY_SOURCES_ORDER = "sources_sort_order"
const val KEY_SOURCES_CATALOG = "sources_catalog"
@@ -768,6 +808,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_BACKUP_TG_CHAT = "backup_periodic_tg_chat_id"
const val KEY_MANGA_LIST_BADGES = "manga_list_badges"
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
const val KEY_APP_VERSION = "app_version"
@@ -780,6 +823,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PROXY_TEST = "proxy_test"
const val KEY_OPEN_BROWSER = "open_browser"
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_TEST = "backup_periodic_tg_test"
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 {
var lastValue: T = valueProducer()
emit(lastValue)
observe().collect {
observeChanges().collect {
if (it == key) {
val value = valueProducer()
if (value != lastValue) {
@@ -25,7 +25,7 @@ fun <T> AppSettings.observeAsStateFlow(
scope: CoroutineScope,
key: String,
valueProducer: AppSettings.() -> T,
): StateFlow<T> = observe().transform {
): StateFlow<T> = observeChanges().transform {
if (it == key) {
emit(valueProducer())
}

View File

@@ -66,8 +66,9 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
companion object {
const val KEY_SORT_ORDER = "sort_order"
const val KEY_SLOWDOWN = "slowdown"
const val KEY_DOMAIN = "domain"
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
import android.content.Context
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.view.View
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
@@ -86,6 +88,12 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
(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) {
val pref = findPreference<Preference>(key)
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.call
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
@@ -43,13 +44,13 @@ abstract class BaseViewModel : ViewModel() {
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job = viewModelScope.launch(context + createErrorHandler(), start, block)
): Job = viewModelScope.launch(context.withDefaultExceptionHandler(), start, block)
protected fun launchLoadingJob(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job = viewModelScope.launch(context + createErrorHandler(), start) {
): Job = viewModelScope.launch(context.withDefaultExceptionHandler(), start) {
loadingCounter.increment()
try {
block()
@@ -80,10 +81,28 @@ abstract class BaseViewModel : ViewModel() {
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
throwable.printStackTraceDebug()
if (throwable !is CancellationException) {
errorEvent.call(throwable)
private fun CoroutineContext.withDefaultExceptionHandler() =
if (this[CoroutineExceptionHandler.Key] is EventExceptionHandler) {
this
} 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

@@ -1,8 +0,0 @@
package org.koitharu.kotatsu.core.ui
import android.view.View
fun interface OnContextClickListenerCompat {
fun onContextClick(v: View): Boolean
}

View File

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

View File

@@ -10,7 +10,7 @@ import coil3.asImage
import coil3.request.Disposable
import coil3.request.ImageRequest
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler.Companion.ignoreCaptchaErrors
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler.Companion.suppressCaptchaErrors
import org.koitharu.kotatsu.core.image.CoilImageView
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
@@ -57,7 +57,7 @@ class FaviconView @JvmOverloads constructor(
.fallback(fallbackFactory)
.placeholder(placeholderFactory)
.mangaSourceExtra(mangaSource)
.ignoreCaptchaErrors()
.suppressCaptchaErrors()
.build(),
)
}

View File

@@ -2,17 +2,16 @@ package org.koitharu.kotatsu.core.ui.list
import android.view.View
import android.view.View.OnClickListener
import android.view.View.OnContextClickListener
import android.view.View.OnLongClickListener
import androidx.core.util.Function
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
class AdapterDelegateClickListenerAdapter<I, O>(
private val adapterDelegate: AdapterDelegateViewBindingViewHolder<out I, *>,
private val clickListener: OnListItemClickListener<O>,
private val itemMapper: Function<I, O>,
) : OnClickListener, OnLongClickListener, OnContextClickListenerCompat {
) : OnClickListener, OnLongClickListener, OnContextClickListener {
override fun onClick(v: View) {
clickListener.onItemClick(mappedItem(), v)
@@ -33,7 +32,7 @@ class AdapterDelegateClickListenerAdapter<I, O>(
fun attach(itemView: View) {
itemView.setOnClickListener(this)
itemView.setOnLongClickListener(this)
itemView.setOnContextClickListenerCompat(this)
itemView.setOnContextClickListener(this)
}
companion object {

View File

@@ -186,6 +186,7 @@ class ListSelectionController(
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_CREATE) {
source.lifecycle.removeObserver(this)
val registry = registryOwner.savedStateRegistry
registry.registerSavedStateProvider(PROVIDER_NAME, this@ListSelectionController)
val state = registry.consumeRestoredStateForKey(PROVIDER_NAME)

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core.ui.util
import android.graphics.Color
import android.os.Build
import android.view.ViewGroup
import android.view.Window
import androidx.activity.OnBackPressedCallback
@@ -14,7 +13,6 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import com.google.android.material.R as materialR
@@ -37,14 +35,10 @@ class ActionModeDelegate : OnBackPressedCallback(false) {
listeners?.forEach { it.onActionModeStarted(mode) }
if (window != null) {
val ctx = window.context
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ColorUtils.compositeColors(
ContextCompat.getColor(ctx, materialR.color.m3_appbar_overlay_color),
ctx.getThemeColor(materialR.attr.colorSurface),
)
} else {
ContextCompat.getColor(ctx, R.color.kotatsu_surface)
}
val actionModeColor = ColorUtils.compositeColors(
ContextCompat.getColor(ctx, materialR.color.m3_appbar_overlay_color),
ctx.getThemeColor(materialR.attr.colorSurface),
)
defaultStatusBarColor = window.statusBarColor
window.statusBarColor = actionModeColor
val insets = ViewCompat.getRootWindowInsets(window.decorView)

View File

@@ -4,12 +4,10 @@ import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
class PopupMenuMediator(
private val provider: MenuProvider,
) : View.OnLongClickListener, OnContextClickListenerCompat, PopupMenu.OnMenuItemClickListener,
) : View.OnLongClickListener, View.OnContextClickListener, PopupMenu.OnMenuItemClickListener,
PopupMenu.OnDismissListener {
override fun onContextClick(v: View): Boolean = onLongClick(v)
@@ -37,6 +35,6 @@ class PopupMenuMediator(
fun attach(view: View) {
view.setOnLongClickListener(this)
view.setOnContextClickListenerCompat(this)
view.setOnContextClickListener(this)
}
}

View File

@@ -22,7 +22,7 @@ open class StackLayout @JvmOverloads constructor(
val h = b - t - paddingTop - paddingBottom
visibleChildren.clear()
children.filterNotTo(visibleChildren) { it.isGone }
if (w <= 0 || h <= 0 || visibleChildren.isEmpty) {
if (w <= 0 || h <= 0 || visibleChildren.isEmpty()) {
return
}
val xStep = w / (visibleChildren.size + 1)

View File

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

View File

@@ -1,46 +0,0 @@
package org.koitharu.kotatsu.core.util
import android.annotation.SuppressLint
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import androidx.work.impl.foreground.SystemForegroundService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import javax.inject.Provider
/**
* Workaround for issue
* https://issuetracker.google.com/issues/270245927
* https://issuetracker.google.com/issues/280504155
*/
class WorkServiceStopHelper(
private val workManagerProvider: Provider<WorkManager>,
) {
fun setup() {
processLifecycleScope.launch(Dispatchers.Default) {
workManagerProvider.get()
.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING))
.map { it.isEmpty() }
.distinctUntilChanged()
.collectLatest {
if (it) {
delay(1_000)
stopWorkerService()
}
}
}
}
@SuppressLint("RestrictedApi")
private fun stopWorkerService() {
SystemForegroundService.getInstance()?.stop()
}
}

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?>.firstNotNullOrNull(): T? = firstOrNull { x -> x != null }

View File

@@ -1,5 +1,8 @@
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.Job
import kotlinx.coroutines.currentCoroutineContext
@@ -12,6 +15,7 @@ import okio.FileSystem
import okio.IOException
import okio.Path
import okio.Source
import okio.source
import org.koitharu.kotatsu.core.util.CancellableSource
import org.koitharu.kotatsu.core.util.progress.ProgressResponseBody
import java.io.ByteArrayOutputStream
@@ -57,3 +61,8 @@ fun FileSystem.isRegularFile(path: Path) = try {
} catch (_: IOException) {
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)
}
fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
fun SharedPreferences.observeChanges(): Flow<String?> = callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, 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 {
emit(valueProducer())
observe().collect { upstreamKey ->
observeChanges().collect { upstreamKey ->
if (upstreamKey == key) {
emit(valueProducer())
}

View File

@@ -6,6 +6,7 @@ import android.database.sqlite.SQLiteFullException
import androidx.annotation.DrawableRes
import coil3.network.HttpException
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import kotlinx.coroutines.CancellationException
import okhttp3.Response
import okhttp3.internal.http2.StreamResetException
import okio.FileNotFoundException
@@ -52,6 +53,7 @@ import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
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_CONNECTION_RESET = "Connection reset"
@@ -63,6 +65,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessag
?: resources.getString(R.string.error_occurred)
private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) {
is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message
is CaughtException -> cause.getDisplayMessageOrNull(resources)
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
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 UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
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) {
404 -> resources.getString(R.string.not_found_404)
403 -> resources.getString(R.string.access_denied_403)
HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404)
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)
else -> null
}

View File

@@ -28,7 +28,6 @@ import com.google.android.material.progressindicator.BaseProgressIndicator
import com.google.android.material.slider.RangeSlider
import com.google.android.material.slider.Slider
import com.google.android.material.tabs.TabLayout
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
import kotlin.math.roundToInt
fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
@@ -169,12 +168,16 @@ fun BaseProgressIndicator<*>.showOrHide(value: Boolean) {
}
}
fun View.setOnContextClickListenerCompat(listener: OnContextClickListenerCompat) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setOnContextClickListener(listener::onContextClick)
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?
get() {
menu // to call ensureMenu()
@@ -201,7 +204,7 @@ fun Chip.setProgressIcon() {
fun View.setContentDescriptionAndTooltip(@StringRes resId: Int) {
val text = resources.getString(resId)
contentDescription = text
TooltipCompat.setTooltipText(this, text)
setTooltipCompat(text)
}
fun View.getWindowBounds(): Rect {

View File

@@ -31,13 +31,12 @@ data class MangaDetails(
val id: Long
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 chapters: Map<String?, List<MangaChapter>> by lazy {
allChapters.groupBy { it.branch }
}
val isLocal
get() = manga.isLocal
@@ -51,7 +50,22 @@ data class MangaDetails(
.ifNullOrEmpty { localManga?.manga?.coverUrl }
?.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? {
findAppropriateLocale(chapters.keys.singleOrNull())?.let {

View File

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

View File

@@ -34,12 +34,17 @@ class ProgressUpdateUseCase @Inject constructor(
}
val chapter = details.findChapterById(history.chapterId) ?: return PROGRESS_NONE
val chapters = details.getChapters(chapter.branch)
val chapterRepo = if (repo.source == chapter.source) {
repo
} else {
mangaRepositoryFactory.create(chapter.source)
}
val chaptersCount = chapters.size
if (chaptersCount == 0) {
return PROGRESS_NONE
}
val chapterIndex = chapters.indexOfFirst { x -> x.id == history.chapterId }
val pagesCount = repo.getPages(chapter).size
val pagesCount = chapterRepo.getPages(chapter).size
if (pagesCount == 0) {
return PROGRESS_NONE
}

View File

@@ -27,7 +27,7 @@ class ReadingTimeUseCase @Inject constructor(
// Impossible task, I guess. Good luck on this.
var averageTimeSec: Int = 20 /* pages */ * getSecondsPerPage(manga.id) * chapters.size
if (isOnHistoryBranch) {
averageTimeSec = (averageTimeSec * (1f - checkNotNull(history).percent)).roundToInt()
averageTimeSec = (averageTimeSec * (1f - history.percent)).roundToInt()
}
if (averageTimeSec < 60) {
return null

View File

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

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.details.ui
import android.app.assist.AssistContent
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.text.SpannedString
import android.view.Gravity
@@ -11,7 +10,6 @@ import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.widget.TooltipCompat
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.core.text.method.LinkMovementMethodCompat
@@ -80,6 +78,7 @@ import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
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.textAndVisible
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
@@ -209,9 +208,7 @@ class DetailsActivity :
override fun onProvideAssistContent(outContent: AssistContent) {
super.onProvideAssistContent(outContent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it }
}
viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it }
}
override fun isNsfwContent(): Flow<Boolean> = viewModel.manga.map { it?.contentRating == ContentRating.ADULT }
@@ -260,7 +257,7 @@ class DetailsActivity :
R.id.button_scrobbling_more -> {
router.showScrobblingSelectorSheet(
manga = viewModel.getMangaOrNull() ?: return,
scrobblerService = viewModel.scrobblingInfo.value.firstOrNull()?.scrobbler
scrobblerService = viewModel.scrobblingInfo.value.firstOrNull()?.scrobbler,
)
}
@@ -389,7 +386,7 @@ class DetailsActivity :
mangaGridItemAD(
sizeResolver = StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)),
) { item, view ->
router.openDetails(item)
router.openDetails(item.toMangaWithOverride())
},
).also { rv.adapter = it }
adapter.items = related
@@ -455,7 +452,7 @@ class DetailsActivity :
textViewSourceLabel.isVisible = false
} else {
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
}
val faviconPlaceholderFactory = FaviconDrawable.Factory(R.style.FaviconDrawable_Chip)

View File

@@ -140,6 +140,7 @@ class DetailsViewModel @Inject constructor(
get() = scrobblers.any { it.isEnabled }
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
.withErrorHandling()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val relatedManga: StateFlow<List<MangaListModel>> = manga.mapLatest {
@@ -182,7 +183,7 @@ class DetailsViewModel @Inject constructor(
init {
loadingJob = doLoad(force = false)
launchJob(Dispatchers.Default) {
launchJob(Dispatchers.Default + SkipErrors) {
val manga = mangaDetails.firstOrNull { !it?.chapters.isNullOrEmpty() } ?: return@launchJob
val h = history.firstOrNull()
if (h != null) {

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import androidx.core.view.MenuProvider
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.slider.LabelFormatter
import com.google.android.material.slider.Slider
import com.google.android.material.slider.TickVisibilityMode
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
@@ -38,9 +39,13 @@ class ChapterPagesMenuProvider(
setOnActionExpandListener(this@ChapterPagesMenuProvider)
(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_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 -> {
@@ -64,6 +69,11 @@ class ChapterPagesMenuProvider(
true
}
R.id.action_downloaded -> {
viewModel.isDownloadedOnly.value = !menuItem.isChecked
true
}
else -> false
}
@@ -110,7 +120,7 @@ class ChapterPagesMenuProvider(
valueFrom = 50f
valueTo = 150f
stepSize = 5f
isTickVisible = false
tickVisibilityMode = TickVisibilityMode.TICK_VISIBILITY_HIDDEN
labelBehavior = LabelFormatter.LABEL_FLOATING
setLabelFormatter(IntPercentLabelFormatter(context))
setValueRounded(settings.gridSizePages.toFloat())

View File

@@ -81,6 +81,7 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(),
val menuInvalidator = MenuInvalidator(binding.toolbar)
viewModel.isChaptersReversed.observe(viewLifecycleOwner, menuInvalidator)
viewModel.isChaptersInGridView.observe(viewLifecycleOwner, menuInvalidator)
viewModel.isDownloadedOnly.observe(viewLifecycleOwner, menuInvalidator)
actionModeDelegate?.addListener(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.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
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.model.LocalManga
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.ReaderState
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
@@ -87,6 +89,8 @@ abstract class ChaptersPagesViewModel(
valueProducer = { isChaptersGridView },
)
val isDownloadedOnly = MutableStateFlow(false)
val newChaptersCount = mangaDetails.flatMapLatest { d ->
if (d?.isLocal == false) {
interactor.observeNewChapters(d.id)
@@ -95,9 +99,19 @@ abstract class ChaptersPagesViewModel(
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
val isChaptersEmpty: StateFlow<Boolean> = mangaDetails.map {
it != null && it.isLoaded && it.allChapters.isEmpty()
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
val emptyReason: StateFlow<EmptyMangaReason?> = combine(
mangaDetails,
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 {
if (it != null) {
@@ -115,13 +129,15 @@ abstract class ChaptersPagesViewModel(
newChaptersCount,
bookmarks,
isChaptersInGridView,
) { manga, currentChapterId, branch, news, bookmarks, grid ->
isDownloadedOnly,
) { manga, currentChapterId, branch, news, bookmarks, grid, downloadedOnly ->
manga?.mapChapters(
currentChapterId,
news,
branch,
bookmarks,
grid,
currentChapterId = currentChapterId,
newCount = news,
branch = branch,
bookmarks = bookmarks,
isGrid = grid,
isDownloadedOnly = downloadedOnly,
).orEmpty()
},
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.findParentCallback
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.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
@@ -96,8 +97,8 @@ class ChaptersFragment :
.flowOn(Dispatchers.Default)
.observe(viewLifecycleOwner, this::onChaptersChanged)
viewModel.quickFilter.observe(viewLifecycleOwner, this::onFilterChanged)
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
binding.textViewHolder.isVisible = it
viewModel.emptyReason.observe(viewLifecycleOwner) {
binding.textViewHolder.setTextAndVisible(it?.msgResId ?: 0)
}
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.details.ui.pager.chapters
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.widget.Toast
import androidx.appcompat.view.ActionMode
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
@@ -11,6 +12,7 @@ import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.list.BaseListSelectionCallback
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toCollection
import org.koitharu.kotatsu.core.util.ext.toSet
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
@@ -78,11 +80,20 @@ class ChaptersSelectionCallback(
ids.size == manga.chapters?.size -> viewModel.deleteLocal()
else -> {
LocalChaptersRemoveService.start(recyclerView.context, manga, ids.toSet())
Snackbar.make(
recyclerView,
R.string.chapters_will_removed_background,
Snackbar.LENGTH_LONG,
).show()
try {
Snackbar.make(
recyclerView,
R.string.chapters_will_removed_background,
Snackbar.LENGTH_LONG,
).show()
} catch (e: IllegalArgumentException) {
e.printStackTraceDebug()
Toast.makeText(
recyclerView.context,
R.string.chapters_will_removed_background,
Toast.LENGTH_SHORT,
).show()
}
}
}
mode?.finish()

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

View File

@@ -11,7 +11,6 @@ import androidx.appcompat.view.ActionMode
import androidx.collection.ArraySet
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
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.observe
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.databinding.FragmentPagesBinding
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.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
@@ -125,11 +126,18 @@ class PagesFragment :
it.spanCount = checkNotNull(spanResolver).spanCount
}
}
parentViewModel.isChaptersEmpty.observe(viewLifecycleOwner, ::onNoChaptersChanged)
parentViewModel.emptyReason.observe(viewLifecycleOwner, ::onNoChaptersChanged)
viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged)
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(binding.recyclerView))
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) }
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.isLoadingDown.observe(viewLifecycleOwner) { binding.progressBarBottom.showOrHide(it) }
}
@@ -237,10 +245,10 @@ class PagesFragment :
spanResolver?.setGridSize(scale, requireViewBinding().recyclerView)
}
private fun onNoChaptersChanged(isNoChapters: Boolean) {
private fun onNoChaptersChanged(reason: EmptyMangaReason?) {
with(viewBinding ?: return) {
textViewHolder.isVisible = isNoChapters
recyclerView.isInvisible = isNoChapters
textViewHolder.setTextAndVisible(reason?.msgResId ?: 0)
recyclerView.isInvisible = reason != null
}
}

View File

@@ -5,8 +5,9 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
@@ -47,16 +48,15 @@ class PagesViewModel @Inject constructor(
)
init {
loadingJob = launchLoadingJob(Dispatchers.Default) {
val firstState = state.firstNotNull()
doInit(firstState)
launchJob(Dispatchers.Default) {
state.collectLatest {
if (it != null) {
launchJob(Dispatchers.Default) {
state.filterNotNull()
.collect {
val prevJob = loadingJob
loadingJob = launchLoadingJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
doInit(it)
}
}
}
}
}
@@ -105,7 +105,14 @@ class PagesViewModel @Inject constructor(
chaptersLoader.peekChapter(it) != null
} ?: state.details.allChapters.firstOrNull()?.id ?: return
if (!chaptersLoader.hasPages(initialChapterId)) {
chaptersLoader.loadSingleChapter(initialChapterId)
var hasPages = chaptersLoader.loadSingleChapter(initialChapterId)
while (!hasPages) {
if (chaptersLoader.loadPrevNextChapter(state.details, initialChapterId, isNext = true)) {
hasPages = chaptersLoader.snapshot().isNotEmpty()
} else {
break
}
}
}
updateList(state.readerState)
}

View File

@@ -7,6 +7,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
@@ -25,6 +26,8 @@ import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject
@@ -35,7 +38,8 @@ class RelatedListViewModel @Inject constructor(
settings: AppSettings,
private val mangaListMapper: MangaListMapper,
mangaDataRepository: MangaDataRepository,
) : MangaListViewModel(settings, mangaDataRepository) {
@LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>,
) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges) {
private val seed = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga
private val repository = mangaRepositoryFactory.create(seed.source)

View File

@@ -6,6 +6,7 @@ import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.hilt.work.HiltWorker
import androidx.work.BackoffPolicy
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.getWorkInputData
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.toFileOrNull
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.writeAllCancellable
import org.koitharu.kotatsu.core.util.progress.RealtimeEtaEstimator
import org.koitharu.kotatsu.download.domain.DownloadProgress
import org.koitharu.kotatsu.download.domain.DownloadState
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.PagesCache
import org.koitharu.kotatsu.local.data.PageCache
import org.koitharu.kotatsu.local.data.TempFileFilter
import org.koitharu.kotatsu.local.data.input.LocalMangaParser
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
@@ -99,7 +104,7 @@ class DownloadWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
@MangaHttpClient private val okHttp: OkHttpClient,
private val cache: PagesCache,
@PageCache private val cache: LocalStorageCache,
private val localMangaRepository: LocalMangaRepository,
private val mangaLock: MangaLock,
private val mangaDataRepository: MangaDataRepository,
@@ -197,7 +202,7 @@ class DownloadWorker @AssistedInject constructor(
?: error("Cannot obtain remote manga instance")
}
val repo = mangaRepositoryFactory.create(manga.source)
val mangaDetails = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
val mangaDetails = if (manga.chapters.isNullOrEmpty() || manga.description.isNullOrEmpty()) repo.getDetails(manga) else manga
output = LocalMangaOutput.getOrCreate(
root = destination,
manga = mangaDetails,
@@ -229,7 +234,7 @@ class DownloadWorker @AssistedInject constructor(
semaphore.withPermit {
runFailsafe {
val url = repo.getPageUrl(page)
val file = cache.get(url)
val file = cache[url]
?: downloadFile(url, destination, repo.source)
output.addPage(
chapter = chapter,
@@ -371,6 +376,25 @@ class DownloadWorker @AssistedInject constructor(
destination: File,
source: MangaSource,
): 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)
slowdownDispatcher.delay(source)
return imageProxyInterceptor.interceptPageRequest(request, okHttp)
@@ -379,22 +403,14 @@ class DownloadWorker @AssistedInject constructor(
var file: File? = null
try {
response.requireBody().use { body ->
file = File(
destination,
buildString {
append(UUID.randomUUID().toString())
MimeTypes.getExtension(body.contentType()?.toMimeType())?.let { ext ->
append('.')
append(ext)
}
append(".tmp")
},
file = destination.createTempFile(
ext = MimeTypes.getExtension(body.contentType()?.toMimeType())
)
file.sink(append = false).buffer().use {
it.writeAllCancellable(body.source())
}
}
} catch (e: CancellationException) {
} catch (e: Exception) {
file?.delete()
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) {
val previousState = currentState
lastPublishedState = state
@@ -537,7 +565,7 @@ class DownloadWorker @AssistedInject constructor(
return
}
val requests = tasks.map { (manga, task) ->
mangaDataRepository.storeManga(manga)
mangaDataRepository.storeManga(manga, replaceExisting = true)
OneTimeWorkRequestBuilder<DownloadWorker>()
.setConstraints(createConstraints(task.allowMeteredNetwork))
.addTag(TAG)

View File

@@ -53,11 +53,7 @@ class MangaSourcesRepository @Inject constructor(
get() = db.getSourcesDao()
val allMangaSources: Set<MangaParserSource> = Collections.unmodifiableSet(
EnumSet.allOf(MangaParserSource::class.java).apply {
if (!BuildConfig.DEBUG) {
remove(MangaParserSource.DUMMY)
}
},
EnumSet.allOf(MangaParserSource::class.java)
)
suspend fun getEnabledSources(): List<MangaSource> {

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.explore.ui.adapter
import android.view.View
import androidx.appcompat.widget.TooltipCompat
import androidx.core.content.ContextCompat
import androidx.core.text.bold
import androidx.core.text.buildSpannedString
@@ -15,6 +14,7 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.drawableStart
import org.koitharu.kotatsu.core.util.ext.recyclerView
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.databinding.ItemExploreButtonsBinding
import org.koitharu.kotatsu.databinding.ItemExploreSourceGridBinding
@@ -126,8 +126,7 @@ fun exploreSourceGridItemAD(
bind {
val title = item.source.getTitle(context)
TooltipCompat.setTooltipText(
itemView,
itemView.setTooltipCompat(
buildSpannedString {
bold {
append(title)

View File

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

View File

@@ -40,6 +40,9 @@ import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga
import kotlinx.coroutines.flow.SharedFlow
private const val PAGE_SIZE = 16
@@ -52,7 +55,8 @@ class FavouritesListViewModel @Inject constructor(
quickFilterFactory: FavoritesListQuickFilter.Factory,
settings: AppSettings,
mangaDataRepository: MangaDataRepository,
) : MangaListViewModel(settings, mangaDataRepository), QuickFilterListener {
@LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>,
) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges), QuickFilterListener {
val categoryId: Long = savedStateHandle[AppRouter.KEY_ID] ?: NO_ID
private val quickFilter = quickFilterFactory.create(categoryId)

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.filter.ui
import androidx.fragment.app.Fragment
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.ViewModelLifecycle
import dagger.hilt.android.scopes.ViewModelScoped
@@ -489,9 +490,27 @@ class FilterCoordinator @Inject constructor(
val filterCoordinator: FilterCoordinator
}
private companion object {
companion object {
const val TAGS_LIMIT = 12
val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1
}
private const val TAGS_LIMIT = 12
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")
}
}
}

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