Compare commits

..

167 Commits

Author SHA1 Message Date
Koitharu
34f6e5232b Update readme 2025-11-04 10:40:44 +02:00
Koitharu
f205c1b3dc Merge branch 'devel' of github.com:KotatsuApp/Kotatsu into devel 2025-11-04 10:35:35 +02:00
Milo Ivir
4b2a487c37 Translated using Weblate (Croatian)
Currently translated at 99.8% (895 of 896 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
2025-11-04 10:34:14 +02:00
MuhamadSyabitHidayattulloh
726ac21974 Translated using Weblate (Indonesian)
Currently translated at 99.8% (895 of 896 strings)

Co-authored-by: MuhamadSyabitHidayattulloh <tebepc@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-11-04 10:34:14 +02:00
Robert Broketa
6b35216949 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (896 of 896 strings)

Co-authored-by: Robert Broketa <robert@broketa.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2025-11-04 10:34:14 +02:00
João Augusto Casagrande
22cae62f17 Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.8% (895 of 896 strings)

Co-authored-by: João Augusto Casagrande <joao.augusto1809@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2025-11-04 10:34:14 +02:00
Oğuz Ersen
4733caf2e6 Translated using Weblate (Turkish)
Currently translated at 100.0% (896 of 896 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2025-11-04 10:34:14 +02:00
Максим Горпиніч
d49103de1f Translated using Weblate (Ukrainian)
Currently translated at 100.0% (896 of 896 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (894 of 894 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (893 of 893 strings)

Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2025-11-04 10:34:14 +02:00
Koitharu
414bab7ce3 Update readme 2025-11-04 10:34:03 +02:00
Koitharu
64c1873eb5 Merge branch 'master' into devel 2025-11-04 09:49:07 +02:00
Koitharu
06a0b5829b Fix crashes
(cherry picked from commit 1d32f53bdd)
2025-11-03 20:27:53 +02:00
Koitharu
0ce2870c8b Fix chapters list not accessible
(cherry picked from commit 5701862661)
2025-11-03 20:27:24 +02:00
Koitharu
f59027666b Fix loading empty manga
(cherry picked from commit 5590ab7c8a)
2025-11-03 20:27:18 +02:00
Nathan Bapin
8513bc6daf Fix forget page when the screen is rotated (#1674)
(cherry picked from commit e2fcfcc7a8)
2025-11-03 20:27:10 +02:00
Koitharu
cceaefc896 Avoid memory leak in ExceptionResolver
(cherry picked from commit 7a3b2a9bb4)
2025-11-03 20:27:05 +02:00
Koitharu
1d32f53bdd Fix crashes 2025-11-03 20:26:15 +02:00
Koitharu
0e98dd8695 Refactor SearchMenuProvider 2025-11-03 20:23:43 +02:00
MuhamadSyabitHidayattulloh
119b7c2ac7 Add filtering options for pinned sources and empty results in search menu 2025-11-02 15:28:24 +02:00
Koitharu
5701862661 Fix chapters list not accessible 2025-11-02 15:26:56 +02:00
Koitharu
5590ab7c8a Fix loading empty manga 2025-11-02 15:12:32 +02:00
Koitharu
9fde0106be Fix code formatting 2025-11-02 11:05:14 +02:00
skepsun
e73f077dc5 remove unnecessary summary 2025-11-02 10:57:46 +02:00
skepsun
c37458d43a Add foldable device support (auto two-page) 2025-11-02 10:57:46 +02:00
Nathan Bapin
e2fcfcc7a8 Fix forget page when the screen is rotated (#1674) 2025-11-02 10:57:25 +02:00
Koitharu
7a3b2a9bb4 Avoid memory leak in ExceptionResolver 2025-11-02 10:56:23 +02:00
Koitharu
881f154b5e Update parsers 2025-11-02 09:47:46 +02:00
Koitharu
34be5d16f2 Merge pull request #1701 from weblate/weblate-kotatsu-strings 2025-11-02 09:22:10 +02:00
Milo Ivir
e7e554648d Translated using Weblate (Croatian)
Currently translated at 100.0% (893 of 893 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
2025-10-30 04:25:59 +00:00
Draken
89a4180b46 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (893 of 893 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-10-30 04:25:55 +00:00
MuhamadSyabitHidayattulloh
4e2e190547 Translated using Weblate (Indonesian)
Currently translated at 100.0% (893 of 893 strings)

Co-authored-by: MuhamadSyabitHidayattulloh <tebepc@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-10-30 04:25:53 +00:00
João Augusto Casagrande
3c557aae6c Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (893 of 893 strings)

Co-authored-by: João Augusto Casagrande <joao.augusto1809@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2025-10-30 04:25:50 +00:00
Nicola Bortoletto
0b00a3675d Translated using Weblate (Italian)
Currently translated at 100.0% (893 of 893 strings)

Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-10-30 04:25:41 +00:00
Alvoracz
8f20be6953 Translated using Weblate (Czech)
Currently translated at 97.8% (874 of 893 strings)

Co-authored-by: Alvoracz <sedlor@seznam.cz>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translation: Kotatsu/Strings
2025-10-30 04:25:35 +00:00
Kanta Sekiguchi
26875c01c6 Translated using Weblate (Japanese)
Currently translated at 90.8% (811 of 893 strings)

Co-authored-by: Kanta Sekiguchi <kanta.sekiguchi360@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2025-10-30 04:25:32 +00:00
Koitharu
4beb34c1a5 Translated using Weblate (Russian)
Currently translated at 99.7% (891 of 893 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
Conrado
1d50ab00c4 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (889 of 889 strings)

Co-authored-by: Conrado <deadlocked53.89@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
Ruffghanor
299cd229ec Translated using Weblate (Portuguese)
Currently translated at 100.0% (889 of 889 strings)

Co-authored-by: Ruffghanor <ruffghanor20@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
Nahid hasan Limon
b02f394cd4 Translated using Weblate (Bengali)
Currently translated at 22.9% (204 of 889 strings)

Co-authored-by: Nahid hasan Limon <nahidhasanlimon401@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/bn/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
Oğuz Ersen
7352f06564 Translated using Weblate (Turkish)
Currently translated at 100.0% (893 of 893 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (889 of 889 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (882 of 882 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
Milo Ivir
1e4861367e Translated using Weblate (Croatian)
Currently translated at 100.0% (882 of 882 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
Draken
bc3208946b Translated using Weblate (Vietnamese)
Currently translated at 100.0% (889 of 889 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (880 of 880 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
MuhamadSyabitHidayattulloh
d5fbb00676 Translated using Weblate (Indonesian)
Currently translated at 100.0% (889 of 889 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (880 of 880 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-26 17:51:58 +02:00
Hecker_01
7514362ca4 Translated using Weblate (Dutch)
Currently translated at 4.0% (36 of 880 strings)

Translated using Weblate (Dutch)

Currently translated at 88.8% (8 of 9 strings)

Added translation using Weblate (Dutch)

Added translation using Weblate (Dutch)

Co-authored-by: Hecker_01 <jesseflantua@icloud.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/nl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nl/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2025-10-26 17:51:58 +02:00
Infy's Tagalog Translations
e76a04bea0 Translated using Weblate (Filipino)
Currently translated at 98.9% (871 of 880 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-26 17:51:58 +02:00
Максим Горпиніч
732a6e7c26 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (889 of 889 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (882 of 882 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (880 of 880 strings)

Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
Макар Разин
f3111dc636 Translated using Weblate (Russian)
Currently translated at 100.0% (876 of 876 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
Nicola Bortoletto
e0e0cf4ecd Translated using Weblate (Italian)
Currently translated at 100.0% (889 of 889 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (882 of 882 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (876 of 876 strings)

Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
Nataniel Dika Kurniawan
50f302a7f8 Translated using Weblate (Indonesian)
Currently translated at 100.0% (876 of 876 strings)

Co-authored-by: Nataniel Dika Kurniawan <hikawaart2@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
google-labs-jules[bot]
500995a9d8 feat(settings): Add "Every 6 hours" option for periodic backups
Adds a new "Every 6 hours" frequency option to the periodic backup settings.

To maintain consistency with the existing preference values, which are stored in days, this new option is represented internally as a fractional value of `0.25` days.

The implementation includes:
- Adding the new string resource and updating the preference arrays.
- Changing the preference type in `AppSettings.kt` from `Long` to `Float` to accommodate the fractional value.
- Updating the millisecond conversion logic to correctly calculate the interval from a float value in days.

This approach avoids a complex data migration and is simpler and safer than changing the base unit for all values from days to hours.
2025-10-26 17:36:51 +02:00
Koitharu
beaf5cc0d5 Remove SavedFilterBackup class 2025-10-26 17:35:29 +02:00
google-labs-jules[bot]
6377de470d feat: Add saved filters to backup and restore
This commit adds support for backing up and restoring saved filters.

- Added a new `SAVED_FILTERS` section to the backup process.
- Implemented the logic to read filters from SharedPreferences during backup and write them back during restore.
- Fixed compilation errors in `AppBackupAgent` and `BackupSectionModel`.
2025-10-26 17:30:49 +02:00
google-labs-jules[bot]
dec45f7851 feat: Add saved filters to backup and restore
This commit adds support for backing up and restoring saved filters.

- Added a new `SAVED_FILTERS` section to the backup process.
- Implemented the logic to read filters from SharedPreferences during backup and write them back during restore.
2025-10-26 17:30:49 +02:00
Koitharu
dbada34a43 Move pull gesture option to reader settings 2025-10-26 17:27:33 +02:00
Koitharu
b62467964e Fix filters on tablet 2025-10-26 17:03:37 +02:00
Koitharu
3249e10931 Exclude broken sources from catalog 2025-10-26 16:47:54 +02:00
Koitharu
0d5229b112 Improve local manga directories config screen 2025-10-26 16:33:59 +02:00
Koitharu
d0ed1fb85f Notify about broken source on list screen 2025-10-21 18:04:05 +03:00
Koitharu
9e5664da3a Reorganize settings 2025-10-21 17:51:11 +03:00
Koitharu
35c158d35a Update readme 2025-10-21 15:51:12 +03:00
Koitharu
464f24e9f0 Fix unwanted touch events when chapters sheet is collapsed 2025-10-21 15:46:34 +03:00
Koitharu
c8a8203c39 Add authors to filter 2025-10-21 14:45:10 +03:00
Koitharu
b414758f32 Improve saved filters 2025-10-21 12:22:30 +03:00
Koitharu
1181860e41 Improve saved filters 2025-10-20 14:18:08 +03:00
Koitharu
e35521f16f Fix code formatting 2025-10-20 09:08:04 +03:00
MuhamadSyabitHidayattulloh
5fb8ff53f9 Feat: Add Saved Filters Feature 2025-10-20 09:07:56 +03:00
Vicente
a66283d035 Backup Restore reading stats 2025-10-20 08:45:40 +03:00
Vicente
a1ba0b8c21 Backup scrobblings 2025-10-20 08:45:40 +03:00
Koitharu
f3b42b9a42 Small improvement for chapter toast setting 2025-10-20 08:42:02 +03:00
google-labs-jules[bot]
aa2f2c17fc feat(reader): Add setting to toggle chapter toast 2025-10-20 08:37:18 +03:00
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
253 changed files with 11536 additions and 7349 deletions

View File

@@ -4,7 +4,7 @@ root = true
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = tab
indent_style = space
insert_final_newline = true
max_line_length = 120
tab_width = 4

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>

View File

@@ -1,9 +1,7 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="OTHER_INDENT_OPTIONS">
<value>
<option name="USE_TAB_CHARACTER" value="true" />
</value>
<value />
</option>
<AndroidXmlCodeStyleSettings>
<option name="LAYOUT_SETTINGS">
@@ -22,40 +20,46 @@
</value>
</option>
</AndroidXmlCodeStyleSettings>
<JavaCodeStyleSettings>
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="android" withSubpackages="true" static="true" />
<package name="androidx" withSubpackages="true" static="true" />
<package name="com" withSubpackages="true" static="true" />
<package name="junit" withSubpackages="true" static="true" />
<package name="net" withSubpackages="true" static="true" />
<package name="org" withSubpackages="true" static="true" />
<package name="java" withSubpackages="true" static="true" />
<package name="javax" withSubpackages="true" static="true" />
<package name="" withSubpackages="true" static="true" />
<emptyLine />
<package name="android" withSubpackages="true" static="false" />
<emptyLine />
<package name="androidx" withSubpackages="true" static="false" />
<emptyLine />
<package name="com" withSubpackages="true" static="false" />
<emptyLine />
<package name="junit" withSubpackages="true" static="false" />
<emptyLine />
<package name="net" withSubpackages="true" static="false" />
<emptyLine />
<package name="org" withSubpackages="true" static="false" />
<emptyLine />
<package name="java" withSubpackages="true" static="false" />
<emptyLine />
<package name="javax" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
<emptyLine />
</value>
</option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="ALLOW_TRAILING_COMMA" value="true" />
<option name="ALLOW_TRAILING_COMMA_COLLECTION_LITERAL_EXPRESSION" value="true" />
<option name="ALLOW_TRAILING_COMMA_VALUE_ARGUMENT_LIST" value="true" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="CMake">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Groovy">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="HTML">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JAVA">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JSON">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="ObjectiveC">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Shell Script">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
@@ -64,7 +68,6 @@
<codeStyleSettings language="XML">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
<arrangement>
<rules>
@@ -179,9 +182,6 @@
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="LINE_COMMENT_ADD_SPACE" value="true" />
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

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>

View File

@@ -1,24 +1,16 @@
> [!IMPORTANT]
> In light of recent challenges — including threating actions from Kakao Entertainment Corp and upcoming Googles
> [new sideloading policy](https://f-droid.org/ru/2025/10/28/sideloading.html) — weve made the difficult decision to shut down Kotatsu and end its support. Were deeply grateful
> to everyone who contributed and to the amazing community that grew around this project.
---
<div align="center">
<a href="https://kotatsu.app">
<img src="./.github/assets/vtuber.png" alt="Kotatsu Logo" title="Kotatsu" width="600"/>
</a>
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in
online content sources.**
# [Kotatsu](https://kotatsu.app)
**[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)
### Download
<div align="left">
* **Recommended:** Download and install APK from [GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest). Application has a built-in self-updating feature.
* Get it on [F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu). The F-Droid build may be a bit outdated and some fixes might be missing.
* Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (Unstable, use at your own risk). Application has a built-in self-updating feature.
</div>
![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)
### Main Features
@@ -35,7 +27,7 @@
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
* Password / fingerprint-protected access to the app
* Automatically sync app data with other devices on the same account
* Support for older devices running Android 5.0+
* Support for older devices running Android 6.0+
</div>
@@ -86,7 +78,8 @@ please head over to the [Weblate project page](https://hosted.weblate.org/engage
</br>
**📌 Pull requests are welcome, if you want: See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
**📌 Pull requests are welcome, if you want:
See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
### Certificate fingerprints
@@ -104,7 +97,9 @@ please head over to the [Weblate project page](https://hosted.weblate.org/engage
<div align="left">
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions.
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications
to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build &
install instructions.
</div>
@@ -112,6 +107,9 @@ 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

@@ -19,10 +19,10 @@ android {
defaultConfig {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
minSdk = 23
targetSdk = 36
versionCode = 1026
versionName = '9.1.2'
versionCode = 1033
versionName = '9.4.1'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
@@ -87,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'
]
}
@@ -154,6 +155,9 @@ dependencies {
implementation libs.androidx.work.runtime
implementation libs.guava
// Foldable/Window layout
implementation libs.androidx.window
implementation libs.androidx.room.runtime
implementation libs.androidx.room.ktx
ksp libs.androidx.room.compiler

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) {

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

@@ -5,6 +5,8 @@ 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
@@ -35,6 +37,11 @@ class DebugSettingsFragment : BasePreferenceFragment(R.string.debug), Preference
true
}
KEY_TEST_PARSER -> {
router.openList(TestMangaSource, null, null)
true
}
else -> super.onPreferenceTreeClick(preference)
}
@@ -60,5 +67,6 @@ class DebugSettingsFragment : BasePreferenceFragment(R.string.debug), Preference
const val KEY_LEAK_CANARY = "leak_canary"
const val KEY_WORK_INSPECTOR = "work_inspector"
const val KEY_TEST_PARSER = "test_parser"
}
}

View File

@@ -1,17 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android">
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:id="@+id/action_leakcanary"
android:key="leak_canary"
android:persistent="false"
android:title="LeakCanary" />
<Preference
android:id="@+id/action_works"
android:key="work_inspector"
android:persistent="false"
android:title="@string/wi_lib_name" />
<Preference
android:key="test_parser"
android:persistent="false"
android:title="@string/test_parser"
app:allowDividerAbove="true" />
</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"

View File

@@ -26,12 +26,17 @@ import org.koitharu.kotatsu.backups.data.model.CategoryBackup
import org.koitharu.kotatsu.backups.data.model.FavouriteBackup
import org.koitharu.kotatsu.backups.data.model.HistoryBackup
import org.koitharu.kotatsu.backups.data.model.MangaBackup
import org.koitharu.kotatsu.backups.data.model.ScrobblingBackup
import org.koitharu.kotatsu.backups.data.model.SourceBackup
import org.koitharu.kotatsu.backups.data.model.StatisticBackup
import org.koitharu.kotatsu.backups.domain.BackupSection
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.CompositeResult
import org.koitharu.kotatsu.core.util.progress.Progress
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.filter.data.PersistableFilter
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.data.TapGridSettings
import java.io.InputStream
@@ -43,220 +48,267 @@ import javax.inject.Inject
@Reusable
class BackupRepository @Inject constructor(
private val database: MangaDatabase,
private val settings: AppSettings,
private val tapGridSettings: TapGridSettings,
private val database: MangaDatabase,
private val settings: AppSettings,
private val tapGridSettings: TapGridSettings,
private val mangaSourcesRepository: MangaSourcesRepository,
private val savedFiltersRepository: SavedFiltersRepository,
) {
private val json = Json {
allowSpecialFloatingPointValues = true
coerceInputValues = true
encodeDefaults = true
ignoreUnknownKeys = true
useAlternativeNames = false
}
private val json = Json {
allowSpecialFloatingPointValues = true
coerceInputValues = true
encodeDefaults = true
ignoreUnknownKeys = true
useAlternativeNames = false
}
suspend fun createBackup(
output: ZipOutputStream,
progress: FlowCollector<Progress>?,
) {
progress?.emit(Progress.INDETERMINATE)
var commonProgress = Progress(0, BackupSection.entries.size)
for (section in BackupSection.entries) {
when (section) {
BackupSection.INDEX -> output.writeJsonArray(
section = BackupSection.INDEX,
data = flowOf(BackupIndex()),
serializer = serializer(),
)
suspend fun createBackup(
output: ZipOutputStream,
progress: FlowCollector<Progress>?,
) {
progress?.emit(Progress.INDETERMINATE)
var commonProgress = Progress(0, BackupSection.entries.size)
for (section in BackupSection.entries) {
when (section) {
BackupSection.INDEX -> output.writeJsonArray(
section = BackupSection.INDEX,
data = flowOf(BackupIndex()),
serializer = serializer(),
)
BackupSection.HISTORY -> output.writeJsonArray(
section = BackupSection.HISTORY,
data = database.getHistoryDao().dump().map { HistoryBackup(it) },
serializer = serializer(),
)
BackupSection.HISTORY -> output.writeJsonArray(
section = BackupSection.HISTORY,
data = database.getHistoryDao().dump().map { HistoryBackup(it) },
serializer = serializer(),
)
BackupSection.CATEGORIES -> output.writeJsonArray(
section = BackupSection.CATEGORIES,
data = database.getFavouriteCategoriesDao().findAll().asFlow().map { CategoryBackup(it) },
serializer = serializer(),
)
BackupSection.CATEGORIES -> output.writeJsonArray(
section = BackupSection.CATEGORIES,
data = database.getFavouriteCategoriesDao().findAll().asFlow().map { CategoryBackup(it) },
serializer = serializer(),
)
BackupSection.FAVOURITES -> output.writeJsonArray(
section = BackupSection.FAVOURITES,
data = database.getFavouritesDao().dump().map { FavouriteBackup(it) },
serializer = serializer(),
)
BackupSection.FAVOURITES -> output.writeJsonArray(
section = BackupSection.FAVOURITES,
data = database.getFavouritesDao().dump().map { FavouriteBackup(it) },
serializer = serializer(),
)
BackupSection.SETTINGS -> output.writeString(
section = BackupSection.SETTINGS,
data = dumpSettings(),
)
BackupSection.SETTINGS -> output.writeString(
section = BackupSection.SETTINGS,
data = dumpSettings(),
)
BackupSection.SETTINGS_READER_GRID -> output.writeString(
section = BackupSection.SETTINGS_READER_GRID,
data = dumpReaderGridSettings(),
)
BackupSection.SETTINGS_READER_GRID -> output.writeString(
section = BackupSection.SETTINGS_READER_GRID,
data = dumpReaderGridSettings(),
)
BackupSection.BOOKMARKS -> output.writeJsonArray(
section = BackupSection.BOOKMARKS,
data = database.getBookmarksDao().dump().map { BookmarkBackup(it.first, it.second) },
serializer = serializer(),
)
BackupSection.BOOKMARKS -> output.writeJsonArray(
section = BackupSection.BOOKMARKS,
data = database.getBookmarksDao().dump().map { BookmarkBackup(it.first, it.second) },
serializer = serializer(),
)
BackupSection.SOURCES -> output.writeJsonArray(
section = BackupSection.SOURCES,
data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) },
serializer = serializer(),
)
}
progress?.emit(commonProgress)
commonProgress++
}
progress?.emit(commonProgress)
}
BackupSection.SOURCES -> output.writeJsonArray(
section = BackupSection.SOURCES,
data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) },
serializer = serializer(),
)
suspend fun restoreBackup(
input: ZipInputStream,
sections: Set<BackupSection>,
progress: FlowCollector<Progress>?,
): CompositeResult {
progress?.emit(Progress.INDETERMINATE)
var commonProgress = Progress(0, sections.size)
var entry = input.nextEntry
var result = CompositeResult.EMPTY
while (entry != null) {
val section = BackupSection.of(entry)
if (section in sections) {
result = result + when (section) {
BackupSection.INDEX -> CompositeResult.EMPTY // useless in our case
BackupSection.HISTORY -> input.readJsonArray<HistoryBackup>(serializer()).restoreToDb {
upsertManga(it.manga)
getHistoryDao().upsert(it.toEntity())
}
BackupSection.SCROBBLING -> output.writeJsonArray(
section = BackupSection.SCROBBLING,
data = database.getScrobblingDao().dumpEnabled().map { ScrobblingBackup(it) },
serializer = serializer(),
)
BackupSection.CATEGORIES -> input.readJsonArray<CategoryBackup>(serializer()).restoreToDb {
getFavouriteCategoriesDao().upsert(it.toEntity())
}
BackupSection.STATS -> output.writeJsonArray(
section = BackupSection.STATS,
data = database.getStatsDao().dumpEnabled().map { StatisticBackup(it) },
serializer = serializer(),
)
BackupSection.FAVOURITES -> input.readJsonArray<FavouriteBackup>(serializer()).restoreToDb {
upsertManga(it.manga)
getFavouritesDao().upsert(it.toEntity())
}
BackupSection.SAVED_FILTERS -> {
val sources = mangaSourcesRepository.getEnabledSources()
val filters = sources.flatMap { source ->
savedFiltersRepository.getAll(source)
}
output.writeJsonArray(
section = BackupSection.SAVED_FILTERS,
data = filters.asFlow(),
serializer = serializer(),
)
}
}
progress?.emit(commonProgress)
commonProgress++
}
progress?.emit(commonProgress)
}
BackupSection.SETTINGS -> input.readMap().let {
settings.upsertAll(it)
CompositeResult.success()
}
suspend fun restoreBackup(
input: ZipInputStream,
sections: Set<BackupSection>,
progress: FlowCollector<Progress>?,
): CompositeResult {
progress?.emit(Progress.INDETERMINATE)
var commonProgress = Progress(0, sections.size)
var entry = input.nextEntry
var result = CompositeResult.EMPTY
while (entry != null) {
val section = BackupSection.of(entry)
if (section in sections) {
result += when (section) {
BackupSection.INDEX -> CompositeResult.EMPTY // useless in our case
BackupSection.HISTORY -> input.readJsonArray<HistoryBackup>(serializer()).restoreToDb {
upsertManga(it.manga)
getHistoryDao().upsert(it.toEntity())
}
BackupSection.SETTINGS_READER_GRID -> input.readMap().let {
tapGridSettings.upsertAll(it)
CompositeResult.success()
}
BackupSection.CATEGORIES -> input.readJsonArray<CategoryBackup>(serializer()).restoreToDb {
getFavouriteCategoriesDao().upsert(it.toEntity())
}
BackupSection.BOOKMARKS -> input.readJsonArray<BookmarkBackup>(serializer()).restoreToDb {
upsertManga(it.manga)
getBookmarksDao().upsert(it.bookmarks.map { b -> b.toEntity() })
}
BackupSection.FAVOURITES -> input.readJsonArray<FavouriteBackup>(serializer()).restoreToDb {
upsertManga(it.manga)
getFavouritesDao().upsert(it.toEntity())
}
BackupSection.SOURCES -> input.readJsonArray<SourceBackup>(serializer()).restoreToDb {
getSourcesDao().upsert(it.toEntity())
}
BackupSection.SETTINGS -> input.readMap().let {
settings.upsertAll(it)
CompositeResult.success()
}
null -> CompositeResult.EMPTY // skip unknown entries
}
progress?.emit(commonProgress)
commonProgress++
}
input.closeEntry()
entry = input.nextEntry
}
progress?.emit(commonProgress)
return result
}
BackupSection.SETTINGS_READER_GRID -> input.readMap().let {
tapGridSettings.upsertAll(it)
CompositeResult.success()
}
private suspend fun <T> ZipOutputStream.writeJsonArray(
section: BackupSection,
data: Flow<T>,
serializer: SerializationStrategy<T>,
) {
data.onStart {
putNextEntry(ZipEntry(section.entryName))
write("[")
}.onCompletion { error ->
if (error == null) {
write("]")
}
closeEntry()
flush()
}.collectIndexed { index, value ->
if (index > 0) {
write(",")
}
json.encodeToStream(serializer, value, this)
}
}
BackupSection.BOOKMARKS -> input.readJsonArray<BookmarkBackup>(serializer()).restoreToDb {
upsertManga(it.manga)
getBookmarksDao().upsert(it.bookmarks.map { b -> b.toEntity() })
}
private fun <T> InputStream.readJsonArray(
serializer: DeserializationStrategy<T>,
): Sequence<T> = json.decodeToSequence(this, serializer, DecodeSequenceMode.ARRAY_WRAPPED)
BackupSection.SOURCES -> input.readJsonArray<SourceBackup>(serializer()).restoreToDb {
getSourcesDao().upsert(it.toEntity())
}
private fun InputStream.readMap(): Map<String, Any?> {
val jo = JSONArray(readString()).getJSONObject(0)
val map = ArrayMap<String, Any?>(jo.length())
val keys = jo.keys()
while (keys.hasNext()) {
val key = keys.next()
map[key] = jo.get(key)
}
return map
}
BackupSection.SCROBBLING -> input.readJsonArray<ScrobblingBackup>(serializer()).restoreToDb {
getScrobblingDao().upsert(it.toEntity())
}
private fun ZipOutputStream.writeString(
section: BackupSection,
data: String,
) {
putNextEntry(ZipEntry(section.entryName))
try {
write("[")
write(data)
write("]")
} finally {
closeEntry()
flush()
}
}
BackupSection.STATS -> input.readJsonArray<StatisticBackup>(serializer()).restoreToDb {
getStatsDao().upsert(it.toEntity())
}
private fun OutputStream.write(str: String) = write(str.toByteArray())
BackupSection.SAVED_FILTERS -> input.readJsonArray<PersistableFilter>(serializer())
.restoreWithoutTransaction {
savedFiltersRepository.save(it)
}
private fun InputStream.readString(): String = readBytes().decodeToString()
null -> CompositeResult.EMPTY // skip unknown entries
}
progress?.emit(commonProgress)
commonProgress++
}
input.closeEntry()
entry = input.nextEntry
}
progress?.emit(commonProgress)
return result
}
private fun dumpSettings(): String {
val map = settings.getAllValues().toMutableMap()
map.remove(AppSettings.KEY_APP_PASSWORD)
map.remove(AppSettings.KEY_PROXY_PASSWORD)
map.remove(AppSettings.KEY_PROXY_LOGIN)
map.remove(AppSettings.KEY_INCOGNITO_MODE)
return JSONObject(map).toString()
}
private suspend fun <T> ZipOutputStream.writeJsonArray(
section: BackupSection,
data: Flow<T>,
serializer: SerializationStrategy<T>,
) {
data.onStart {
putNextEntry(ZipEntry(section.entryName))
write("[")
}.onCompletion { error ->
if (error == null) {
write("]")
}
closeEntry()
flush()
}.collectIndexed { index, value ->
if (index > 0) {
write(",")
}
json.encodeToStream(serializer, value, this)
}
}
private fun dumpReaderGridSettings(): String {
return JSONObject(tapGridSettings.getAllValues()).toString()
}
private fun <T> InputStream.readJsonArray(
serializer: DeserializationStrategy<T>,
): Sequence<T> = json.decodeToSequence(this, serializer, DecodeSequenceMode.ARRAY_WRAPPED)
private suspend fun MangaDatabase.upsertManga(manga: MangaBackup) {
val tags = manga.tags.map { it.toEntity() }
getTagsDao().upsert(tags)
getMangaDao().upsert(manga.toEntity(), tags)
}
private fun InputStream.readMap(): Map<String, Any?> {
val jo = JSONArray(readString()).getJSONObject(0)
val map = ArrayMap<String, Any?>(jo.length())
val keys = jo.keys()
while (keys.hasNext()) {
val key = keys.next()
map[key] = jo.get(key)
}
return map
}
private suspend inline fun <T> Sequence<T>.restoreToDb(crossinline block: suspend MangaDatabase.(T) -> Unit): CompositeResult {
return fold(CompositeResult.EMPTY) { result, item ->
result + runCatchingCancellable {
database.withTransaction {
database.block(item)
}
}
}
}
private fun ZipOutputStream.writeString(
section: BackupSection,
data: String,
) {
putNextEntry(ZipEntry(section.entryName))
try {
write("[")
write(data)
write("]")
} finally {
closeEntry()
flush()
}
}
private fun OutputStream.write(str: String) = write(str.toByteArray())
private fun InputStream.readString(): String = readBytes().decodeToString()
private fun dumpSettings(): String {
val map = settings.getAllValues().toMutableMap()
map.remove(AppSettings.KEY_APP_PASSWORD)
map.remove(AppSettings.KEY_PROXY_PASSWORD)
map.remove(AppSettings.KEY_PROXY_LOGIN)
map.remove(AppSettings.KEY_INCOGNITO_MODE)
return JSONObject(map).toString()
}
private fun dumpReaderGridSettings(): String {
return JSONObject(tapGridSettings.getAllValues()).toString()
}
private suspend fun MangaDatabase.upsertManga(manga: MangaBackup) {
val tags = manga.tags.map { it.toEntity() }
getTagsDao().upsert(tags)
getMangaDao().upsert(manga.toEntity(), tags)
}
private suspend inline fun <T> Sequence<T>.restoreToDb(crossinline block: suspend MangaDatabase.(T) -> Unit): CompositeResult {
return fold(CompositeResult.EMPTY) { result, item ->
result + runCatchingCancellable {
database.withTransaction {
database.block(item)
}
}
}
}
private suspend inline fun <T> Sequence<T>.restoreWithoutTransaction(crossinline block: suspend (T) -> Unit): CompositeResult {
return fold(CompositeResult.EMPTY) { result, item ->
result + runCatchingCancellable {
block(item)
}
}
}
}

View File

@@ -0,0 +1,40 @@
package org.koitharu.kotatsu.backups.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
@Serializable
class ScrobblingBackup(
@SerialName("scrobbler") val scrobbler: Int,
@SerialName("id") val id: Int,
@SerialName("manga_id") val mangaId: Long,
@SerialName("target_id") val targetId: Long,
@SerialName("status") val status: String?,
@SerialName("chapter") val chapter: Int,
@SerialName("comment") val comment: String?,
@SerialName("rating") val rating: Float,
) {
constructor(entity: ScrobblingEntity) : this(
scrobbler = entity.scrobbler,
id = entity.id,
mangaId = entity.mangaId,
targetId = entity.targetId,
status = entity.status,
chapter = entity.chapter,
comment = entity.comment,
rating = entity.rating,
)
fun toEntity() = ScrobblingEntity(
scrobbler = scrobbler,
id = id,
mangaId = mangaId,
targetId = targetId,
status = status,
chapter = chapter,
comment = comment,
rating = rating,
)
}

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.backups.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.koitharu.kotatsu.stats.data.StatsEntity
@Serializable
class StatisticBackup(
@SerialName("manga_id") val mangaId: Long,
@SerialName("started_at") val startedAt: Long,
@SerialName("duration") val duration: Long,
@SerialName("pages") val pages: Int,
) {
constructor(entity: StatsEntity) : this(
mangaId = entity.mangaId,
startedAt = entity.startedAt,
duration = entity.duration,
pages = entity.pages,
)
fun toEntity() = StatsEntity(
mangaId = mangaId,
startedAt = startedAt,
duration = duration,
pages = pages,
)
}

View File

@@ -12,6 +12,8 @@ import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.backups.data.BackupRepository
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
import org.koitharu.kotatsu.reader.data.TapGridSettings
import java.io.File
import java.io.FileDescriptor
@@ -36,15 +38,22 @@ 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(
database = MangaDatabase(context = applicationContext),
settings = AppSettings(applicationContext),
tapGridSettings = TapGridSettings(applicationContext),
mangaSourcesRepository = MangaSourcesRepository(
context = applicationContext,
db = MangaDatabase(context = applicationContext),
settings = AppSettings(applicationContext),
),
)
savedFiltersRepository = SavedFiltersRepository(
context = applicationContext,
),
),
)
try {
fullBackupFile(file, data)
} finally {
@@ -68,6 +77,14 @@ class AppBackupAgent : BackupAgent() {
database = MangaDatabase(applicationContext),
settings = AppSettings(applicationContext),
tapGridSettings = TapGridSettings(applicationContext),
mangaSourcesRepository = MangaSourcesRepository(
context = applicationContext,
db = MangaDatabase(context = applicationContext),
settings = AppSettings(applicationContext),
),
savedFiltersRepository = SavedFiltersRepository(
context = applicationContext,
),
),
)
destination.delete()
@@ -90,8 +107,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

@@ -15,13 +15,16 @@ enum class BackupSection(
SETTINGS_READER_GRID("reader_grid"),
BOOKMARKS("bookmarks"),
SOURCES("sources"),
SCROBBLING("scrobbling"),
STATS("statistics"),
SAVED_FILTERS("saved_filters"),
;
companion object {
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

@@ -36,7 +36,7 @@ class TelegramBackupUploader @Inject constructor(
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

@@ -23,6 +23,9 @@ data class BackupSectionModel(
BackupSection.SETTINGS_READER_GRID -> R.string.reader_actions
BackupSection.BOOKMARKS -> R.string.bookmarks
BackupSection.SOURCES -> R.string.remote_sources
BackupSection.SCROBBLING -> R.string.tracking
BackupSection.STATS -> R.string.statistics
BackupSection.SAVED_FILTERS -> R.string.saved_filters
}
override fun areItemsTheSame(other: ListModel): Boolean {

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

@@ -34,6 +34,9 @@ abstract class MangaDao {
@Query("SELECT author FROM manga WHERE author LIKE :query GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit")
abstract suspend fun findAuthors(query: String, limit: Int): List<String>
@Query("SELECT author FROM manga WHERE manga.source = :source AND author IS NOT NULL AND author != '' GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit")
abstract suspend fun findAuthorsBySource(source: String, limit: Int): List<String>
@Transaction
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>

View File

@@ -0,0 +1,10 @@
package org.koitharu.kotatsu.core.exceptions
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
import org.koitharu.kotatsu.parsers.model.Manga
class EmptyMangaException(
val reason: EmptyMangaReason?,
val manga: Manga,
cause: Throwable?
) : IllegalStateException(cause)

View File

@@ -7,6 +7,7 @@ import android.content.Context
import android.content.Intent
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
@@ -43,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
@@ -65,11 +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()
@CheckResult
suspend fun handle(exception: CloudFlareException): Boolean = handleException(exception.source, exception, true)
suspend fun discard(source: MangaSource) {
@@ -79,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
}
}
}
}
@@ -90,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) {
@@ -119,7 +134,7 @@ class CaptchaHandler @Inject constructor(
notify(exceptions)
}
}
true
false
}
@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
@@ -234,7 +249,7 @@ class CaptchaHandler @Inject constructor(
.data(source.faviconUri())
.allowHardware(false)
.allowConversionToBitmap(true)
.ignoreCaptchaErrors()
.suppressCaptchaErrors()
.mangaSourceExtra(source)
.size(context.resources.getNotificationIconSize())
.scale(Scale.FILL)
@@ -260,11 +275,11 @@ class CaptchaHandler @Inject constructor(
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
@@ -272,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

@@ -8,13 +8,15 @@ import androidx.collection.MutableScatterMap
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.async
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
@@ -24,6 +26,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
import org.koitharu.kotatsu.core.util.ext.restartApplication
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
@@ -32,164 +35,221 @@ import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredExcept
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import java.security.cert.CertPathValidatorException
import javax.inject.Inject
import javax.inject.Provider
import javax.net.ssl.SSLException
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class ExceptionResolver @AssistedInject constructor(
@Assisted private val host: Host,
private val settings: AppSettings,
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
class ExceptionResolver private constructor(
private val host: Host,
private val settings: AppSettings,
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
) {
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
private val browserActionContract = host.registerForActivityResult(BrowserActivity.Contract()) {
handleActivityResult(BrowserActivity.TAG, true)
}
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
handleActivityResult(SourceAuthActivity.TAG, it)
}
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
handleActivityResult(CloudFlareActivity.TAG, it)
}
private val browserActionContract = host.registerForActivityResult(BrowserActivity.Contract()) {
handleActivityResult(BrowserActivity.TAG, true)
}
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
handleActivityResult(SourceAuthActivity.TAG, it)
}
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
handleActivityResult(CloudFlareActivity.TAG, it)
}
fun showErrorDetails(e: Throwable, url: String? = null) {
host.router()?.showErrorDialog(e, url)
}
fun showErrorDetails(e: Throwable, url: String? = null) {
host.router.showErrorDialog(e, url)
}
suspend fun resolve(e: Throwable): Boolean = when (e) {
is CloudFlareProtectedException -> resolveCF(e)
is AuthRequiredException -> resolveAuthException(e.source)
is SSLException,
is CertPathValidatorException -> {
showSslErrorDialog()
false
}
suspend fun resolve(e: Throwable): Boolean = host.lifecycleScope.async {
when (e) {
is CloudFlareProtectedException -> resolveCF(e)
is AuthRequiredException -> resolveAuthException(e.source)
is SSLException,
is CertPathValidatorException -> {
showSslErrorDialog()
false
}
is InteractiveActionRequiredException -> resolveBrowserAction(e)
is InteractiveActionRequiredException -> resolveBrowserAction(e)
is ProxyConfigException -> {
host.router()?.openProxySettings()
false
}
is ProxyConfigException -> {
host.router.openProxySettings()
false
}
is NotFoundException -> {
openInBrowser(e.url)
false
}
is NotFoundException -> {
openInBrowser(e.url)
false
}
is UnsupportedSourceException -> {
e.manga?.let { openAlternatives(it) }
false
}
is EmptyMangaException -> {
when (e.reason) {
EmptyMangaReason.NO_CHAPTERS -> openAlternatives(e.manga)
EmptyMangaReason.LOADING_ERROR -> Unit
EmptyMangaReason.RESTRICTED -> host.router.openBrowser(e.manga)
else -> Unit
}
false
}
is ScrobblerAuthRequiredException -> {
val authHelper = scrobblerAuthHelperProvider.get()
if (authHelper.isAuthorized(e.scrobbler)) {
true
} else {
host.withContext {
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
}
false
}
}
is UnsupportedSourceException -> {
e.manga?.let { openAlternatives(it) }
false
}
else -> false
}
is ScrobblerAuthRequiredException -> {
val authHelper = scrobblerAuthHelperProvider.get()
if (authHelper.isAuthorized(e.scrobbler)) {
true
} else {
host.withContext {
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
}
false
}
}
private suspend fun resolveBrowserAction(
e: InteractiveActionRequiredException
): Boolean = suspendCoroutine { cont ->
continuations[BrowserActivity.TAG] = cont
browserActionContract.launch(e)
}
else -> false
}
}.await()
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
continuations[CloudFlareActivity.TAG] = cont
cloudflareContract.launch(e)
}
private suspend fun resolveBrowserAction(
e: InteractiveActionRequiredException
): Boolean = suspendCoroutine { cont ->
continuations[BrowserActivity.TAG] = cont
browserActionContract.launch(e)
}
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
continuations[SourceAuthActivity.TAG] = cont
sourceAuthContract.launch(source)
}
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
continuations[CloudFlareActivity.TAG] = cont
cloudflareContract.launch(e)
}
private fun openInBrowser(url: String) {
host.router()?.openBrowser(url, null, null)
}
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
continuations[SourceAuthActivity.TAG] = cont
sourceAuthContract.launch(source)
}
private fun openAlternatives(manga: Manga) {
host.router()?.openAlternatives(manga)
}
private fun openInBrowser(url: String) {
host.router.openBrowser(url, null, null)
}
private fun handleActivityResult(tag: String, result: Boolean) {
continuations.remove(tag)?.resume(result)
}
private fun openAlternatives(manga: Manga) {
host.router.openAlternatives(manga)
}
private fun showSslErrorDialog() {
val ctx = host.getContext() ?: return
if (settings.isSSLBypassEnabled) {
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
return
}
buildAlertDialog(ctx) {
setTitle(R.string.ignore_ssl_errors)
setMessage(R.string.ignore_ssl_errors_summary)
setPositiveButton(R.string.apply) { _, _ ->
settings.isSSLBypassEnabled = true
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
ctx.restartApplication()
}
setNegativeButton(android.R.string.cancel, null)
}.show()
}
private fun handleActivityResult(tag: String, result: Boolean) {
continuations.remove(tag)?.resume(result)
}
private inline fun Host.withContext(block: Context.() -> Unit) {
getContext()?.apply(block)
}
private fun showSslErrorDialog() {
val ctx = host.context ?: return
if (settings.isSSLBypassEnabled) {
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
return
}
buildAlertDialog(ctx) {
setTitle(R.string.ignore_ssl_errors)
setMessage(R.string.ignore_ssl_errors_summary)
setPositiveButton(R.string.apply) { _, _ ->
settings.isSSLBypassEnabled = true
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
ctx.restartApplication()
}
setNegativeButton(android.R.string.cancel, null)
}.show()
}
private fun Host.router(): AppRouter? = when (this) {
is FragmentActivity -> router
is Fragment -> router
else -> null
}
class Factory @Inject constructor(
private val settings: AppSettings,
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
) {
interface Host : ActivityResultCaller {
fun create(fragment: Fragment) = ExceptionResolver(
host = Host.FragmentHost(fragment),
settings = settings,
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
)
fun getChildFragmentManager(): FragmentManager
fun create(activity: FragmentActivity) = ExceptionResolver(
host = Host.ActivityHost(activity),
settings = settings,
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
)
}
fun getContext(): Context?
}
private sealed interface Host : ActivityResultCaller, LifecycleOwner {
@AssistedFactory
interface Factory {
val context: Context?
fun create(host: Host): ExceptionResolver
}
val router: AppRouter
companion object {
val fragmentManager: FragmentManager
@StringRes
fun getResolveStringId(e: Throwable) = when (e) {
is CloudFlareProtectedException -> R.string.captcha_solve
is ScrobblerAuthRequiredException,
is AuthRequiredException -> R.string.sign_in
inline fun withContext(block: Context.() -> Unit) {
context?.apply(block)
}
is NotFoundException -> if (e.url.isHttpUrl()) R.string.open_in_browser else 0
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
is SSLException,
is CertPathValidatorException -> R.string.fix
class ActivityHost(val activity: FragmentActivity) : Host,
ActivityResultCaller by activity,
LifecycleOwner by activity {
is ProxyConfigException -> R.string.settings
override val context: Context
get() = activity
is InteractiveActionRequiredException -> R.string._continue
override val router: AppRouter
get() = activity.router
else -> 0
}
override val fragmentManager: FragmentManager
get() = activity.supportFragmentManager
}
fun canResolve(e: Throwable) = getResolveStringId(e) != 0
}
class FragmentHost(val fragment: Fragment) : Host,
ActivityResultCaller by fragment {
override val context: Context?
get() = fragment.context
override val router: AppRouter
get() = fragment.router
override val fragmentManager: FragmentManager
get() = fragment.childFragmentManager
override val lifecycle: Lifecycle
get() = fragment.viewLifecycleOwner.lifecycle
}
}
companion object {
@StringRes
fun getResolveStringId(e: Throwable) = when (e) {
is CloudFlareProtectedException -> R.string.captcha_solve
is ScrobblerAuthRequiredException,
is AuthRequiredException -> R.string.sign_in
is NotFoundException -> if (e.url.isHttpUrl()) R.string.open_in_browser else 0
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
is SSLException,
is CertPathValidatorException -> R.string.fix
is ProxyConfigException -> R.string.settings
is InteractiveActionRequiredException -> R.string._continue
is EmptyMangaException -> when (e.reason) {
EmptyMangaReason.RESTRICTED -> if (e.manga.publicUrl.isHttpUrl()) R.string.open_in_browser else 0
EmptyMangaReason.NO_CHAPTERS -> R.string.alternatives
else -> 0
}
else -> 0
}
fun canResolve(e: Throwable) = getResolveStringId(e) != 0
}
}

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

@@ -0,0 +1,20 @@
package org.koitharu.kotatsu.core.model
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.serialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.koitharu.kotatsu.parsers.model.MangaSource
object MangaSourceSerializer : KSerializer<MangaSource> {
override val descriptor: SerialDescriptor = serialDescriptor<String>()
override fun serialize(
encoder: Encoder,
value: MangaSource
) = encoder.encodeString(value.name)
override fun deserialize(decoder: Decoder): MangaSource = MangaSource(decoder.decodeString())
}

File diff suppressed because it is too large Load Diff

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

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

@@ -1,46 +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.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.SortOrder
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 filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities()
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
override suspend fun getList(
offset: Int,
order: SortOrder,
filter: MangaListFilter
): List<Manga> = stub(null)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
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
@@ -189,6 +191,11 @@ class MangaDataRepository @Inject constructor(
emitInitialState = emitInitialState,
)
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()) {

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

@@ -19,6 +19,7 @@ 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
@@ -41,7 +42,6 @@ import org.koitharu.kotatsu.local.data.LocalStorageCache
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File
import javax.inject.Inject
import kotlin.coroutines.coroutineContext
import coil3.Uri as CoilUri
class FaviconFetcher(
@@ -88,7 +88,7 @@ class FaviconFetcher(
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)

View File

@@ -138,6 +138,15 @@ 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) }
var isReaderDoubleOnFoldable: Boolean
get() = prefs.getBoolean(KEY_READER_DOUBLE_FOLDABLE, false)
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_FOLDABLE, 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
@@ -404,6 +413,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderBarTransparent: Boolean
get() = prefs.getBoolean(KEY_READER_BAR_TRANSPARENT, true)
val isReaderChapterToastEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_CHAPTER_TOAST, true)
val isReaderKeepScreenOn: Boolean
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
@@ -488,6 +500,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
@@ -534,11 +550,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isPeriodicalBackupEnabled: Boolean
get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false)
val periodicalBackupFrequency: Long
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L
val periodicalBackupFrequency: Float
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toFloatOrNull() ?: 7f
val periodicalBackupFrequencyMillis: Long
get() = TimeUnit.DAYS.toMillis(periodicalBackupFrequency)
get() = (TimeUnit.DAYS.toMillis(1) * periodicalBackupFrequency).toLong()
val periodicalBackupMaxCount: Int
get() = if (prefs.getBoolean(KEY_BACKUP_PERIODICAL_TRIM, true)) {
@@ -669,6 +685,8 @@ 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_2"
const val KEY_READER_DOUBLE_FOLDABLE = "reader_double_foldable"
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"
@@ -737,6 +755,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SYNC_SETTINGS = "sync_settings"
const val KEY_READER_BAR = "reader_bar"
const val KEY_READER_BAR_TRANSPARENT = "reader_bar_transparent"
const val KEY_READER_CHAPTER_TOAST = "reader_chapter_toast"
const val KEY_READER_BACKGROUND = "reader_background"
const val KEY_READER_SCREEN_ON = "reader_screen_on"
const val KEY_SHORTCUTS = "dynamic_shortcuts"
@@ -748,6 +767,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"

View File

@@ -13,10 +13,14 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.settings.utils.validation.DomainValidator
import java.io.File
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE)
private val prefs = context.getSharedPreferences(
source.name.replace(File.separatorChar, '$'),
Context.MODE_PRIVATE,
)
var defaultSortOrder: SortOrder?
get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java)

View File

@@ -33,7 +33,6 @@ import androidx.appcompat.R as appcompatR
abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(),
ExceptionResolver.Host,
OnApplyWindowInsetsListener,
ScreenshotPolicyHelper.ContentContainer {
@@ -87,10 +86,6 @@ abstract class BaseActivity<B : ViewBinding> :
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
override fun setContentView(view: View?) = throw UnsupportedOperationException()
override fun getContext() = this
override fun getChildFragmentManager(): FragmentManager = supportFragmentManager
protected fun setContentView(binding: B) {
this.viewBinding = binding
super.setContentView(binding.root)

View File

@@ -15,8 +15,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
abstract class BaseFragment<B : ViewBinding> :
OnApplyWindowInsetsListener,
Fragment(),
ExceptionResolver.Host {
Fragment() {
var viewBinding: B? = null
private set

View File

@@ -36,8 +36,7 @@ import com.google.android.material.R as materialR
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(),
OnApplyWindowInsetsListener,
RecyclerViewOwner,
ExceptionResolver.Host {
RecyclerViewOwner {
protected lateinit var exceptionResolver: ExceptionResolver
private set

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

@@ -2,10 +2,17 @@ package org.koitharu.kotatsu.core.ui.dialog
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.view.inputmethod.EditorInfo
import android.widget.ArrayAdapter
import android.widget.CompoundButton.OnCheckedChangeListener
import android.widget.EditText
import android.widget.FrameLayout
import androidx.annotation.StringRes
import androidx.annotation.UiContext
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@@ -15,54 +22,103 @@ import com.hannesdorfmann.adapterdelegates4.AdapterDelegatesManager
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
import org.koitharu.kotatsu.databinding.ViewDialogAutocompleteBinding
import com.google.android.material.R as materialR
inline fun buildAlertDialog(
@UiContext context: Context,
isCentered: Boolean = false,
block: MaterialAlertDialogBuilder.() -> Unit,
@UiContext context: Context,
isCentered: Boolean = false,
block: MaterialAlertDialogBuilder.() -> Unit,
): AlertDialog = MaterialAlertDialogBuilder(
context,
if (isCentered) materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered else 0,
context,
if (isCentered) materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered else 0,
).apply(block).create()
fun <B : AlertDialog.Builder> B.setCheckbox(
@StringRes textResId: Int,
isChecked: Boolean,
onCheckedChangeListener: OnCheckedChangeListener
@StringRes textResId: Int,
isChecked: Boolean,
onCheckedChangeListener: OnCheckedChangeListener
) = apply {
val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
binding.checkbox.setText(textResId)
binding.checkbox.isChecked = isChecked
binding.checkbox.setOnCheckedChangeListener(onCheckedChangeListener)
setView(binding.root)
val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
binding.checkbox.setText(textResId)
binding.checkbox.isChecked = isChecked
binding.checkbox.setOnCheckedChangeListener(onCheckedChangeListener)
setView(binding.root)
}
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
list: List<T>,
delegate: AdapterDelegate<List<T>>,
list: List<T>,
delegate: AdapterDelegate<List<T>>,
) = apply {
val delegatesManager = AdapterDelegatesManager<List<T>>()
delegatesManager.addDelegate(delegate)
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
val delegatesManager = AdapterDelegatesManager<List<T>>()
delegatesManager.addDelegate(delegate)
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
}
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
list: List<T>,
vararg delegates: AdapterDelegate<List<T>>,
list: List<T>,
vararg delegates: AdapterDelegate<List<T>>,
) = apply {
val delegatesManager = AdapterDelegatesManager<List<T>>()
delegates.forEach { delegatesManager.addDelegate(it) }
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
val delegatesManager = AdapterDelegatesManager<List<T>>()
delegates.forEach { delegatesManager.addDelegate(it) }
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
}
fun <B : AlertDialog.Builder> B.setRecyclerViewList(adapter: RecyclerView.Adapter<*>) = apply {
val recyclerView = RecyclerView(context)
recyclerView.layoutManager = LinearLayoutManager(context)
recyclerView.updatePadding(
top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing),
)
recyclerView.clipToPadding = false
recyclerView.adapter = adapter
setView(recyclerView)
val recyclerView = RecyclerView(context)
recyclerView.layoutManager = LinearLayoutManager(context)
recyclerView.updatePadding(
top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing),
)
recyclerView.clipToPadding = false
recyclerView.adapter = adapter
setView(recyclerView)
}
fun <B : AlertDialog.Builder> B.setEditText(
inputType: Int,
singleLine: Boolean,
): EditText {
val editText = AppCompatEditText(context)
editText.inputType = inputType
if (singleLine) {
editText.setSingleLine()
editText.imeOptions = EditorInfo.IME_ACTION_DONE
}
val layout = FrameLayout(context)
val lp = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
val horizontalMargin = context.resources.getDimensionPixelOffset(R.dimen.screen_padding)
lp.setMargins(
horizontalMargin,
context.resources.getDimensionPixelOffset(R.dimen.margin_small),
horizontalMargin,
0,
)
layout.addView(editText, lp)
setView(layout)
return editText
}
fun <B : AlertDialog.Builder> B.setEditText(
entries: List<CharSequence>,
inputType: Int,
singleLine: Boolean,
): EditText {
if (entries.isEmpty()) {
return setEditText(inputType, singleLine)
}
val binding = ViewDialogAutocompleteBinding.inflate(LayoutInflater.from(context))
binding.autoCompleteTextView.setAdapter(
ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, entries),
)
binding.dropdown.setOnClickListener {
binding.autoCompleteTextView.showDropDown()
}
binding.autoCompleteTextView.inputType = inputType
if (singleLine) {
binding.autoCompleteTextView.setSingleLine()
binding.autoCompleteTextView.imeOptions = EditorInfo.IME_ACTION_DONE
}
setView(binding.root)
return binding.autoCompleteTextView
}

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

@@ -5,7 +5,10 @@ import android.view.View
import androidx.annotation.Px
import androidx.recyclerview.widget.RecyclerView
class SpacingItemDecoration(@Px private val spacing: Int) : RecyclerView.ItemDecoration() {
class SpacingItemDecoration(
@Px private val spacing: Int,
private val withBottomPadding: Boolean,
) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
@@ -13,6 +16,6 @@ class SpacingItemDecoration(@Px private val spacing: Int) : RecyclerView.ItemDec
parent: RecyclerView,
state: RecyclerView.State,
) {
outRect.set(spacing, spacing, spacing, spacing)
outRect.set(spacing, spacing, spacing, if (withBottomPadding) spacing else 0)
}
}

View File

@@ -32,8 +32,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import com.google.android.material.R as materialR
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(),
OnApplyWindowInsetsListener,
ExceptionResolver.Host {
OnApplyWindowInsetsListener {
private var waitingForDismissAllowingStateLoss = false
private var isFitToContentsDisabled = false

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

@@ -56,6 +56,11 @@ class ChipsView @JvmOverloads constructor(
val data = it.tag
onChipCloseClickListener?.onChipCloseClick(chip, data) ?: onChipClickListener?.onChipClick(chip, data)
}
private val chipOnLongClickListener = OnLongClickListener {
val chip = it as Chip
val data = it.tag
onChipLongClickListener?.onChipLongClick(chip, data) ?: false
}
private val chipStyle: Int
private val iconsVisible: Boolean
var onChipClickListener: OnChipClickListener? = null
@@ -66,6 +71,8 @@ class ChipsView @JvmOverloads constructor(
}
var onChipCloseClickListener: OnChipCloseClickListener? = null
var onChipLongClickListener: OnChipLongClickListener? = null
init {
val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0)
chipStyle = ta.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip)
@@ -145,6 +152,7 @@ class ChipsView @JvmOverloads constructor(
setOnCloseIconClickListener(chipOnCloseListener)
setEnsureMinTouchTargetSize(false)
setOnClickListener(chipOnClickListener)
setOnLongClickListener(chipOnLongClickListener)
isElegantTextHeight = false
}
@@ -276,4 +284,9 @@ class ChipsView @JvmOverloads constructor(
fun onChipCloseClick(chip: Chip, data: Any?)
}
fun interface OnChipLongClickListener {
fun onChipLongClick(chip: Chip, data: Any?): Boolean
}
}

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

@@ -0,0 +1,22 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.widget.FrameLayout
class TouchBlockLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
var isTouchEventsAllowed = true
override fun onInterceptTouchEvent(
ev: MotionEvent?
): Boolean = if (isTouchEventsAllowed) {
super.onInterceptTouchEvent(ev)
} else {
true
}
}

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

@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.exceptions.CaughtException
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
@@ -62,216 +63,219 @@ private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
private val FNFE_MESSAGE_REGEX = Regex("^(/[^\\s:]+)?.+?\\s([A-Z]{2,6})?\\s.+$")
fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessageOrNull(resources)
?: resources.getString(R.string.error_occurred)
?: 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(
R.string.scrobbler_auth_required,
resources.getString(scrobbler.titleResId),
)
is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message
is CaughtException -> cause.getDisplayMessageOrNull(resources)
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
is ScrobblerAuthRequiredException -> resources.getString(
R.string.scrobbler_auth_required,
resources.getString(scrobbler.titleResId),
)
is AuthRequiredException -> resources.getString(R.string.auth_required)
is InteractiveActionRequiredException -> resources.getString(R.string.additional_action_required)
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message)
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
is ActivityNotFoundException,
is UnsupportedOperationException,
-> resources.getString(R.string.operation_not_supported)
is AuthRequiredException -> resources.getString(R.string.auth_required)
is InteractiveActionRequiredException -> resources.getString(R.string.additional_action_required)
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message)
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
is ActivityNotFoundException,
is UnsupportedOperationException,
-> resources.getString(R.string.operation_not_supported)
is TooManyRequestExceptions -> {
val delay = getRetryDelay()
val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) {
resources.formatDurationShort(delay)
} else {
null
}
if (formattedTime != null) {
resources.getString(R.string.too_many_requests_message_retry, formattedTime)
} else {
resources.getString(R.string.too_many_requests_message)
}
}
is TooManyRequestExceptions -> {
val delay = getRetryDelay()
val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) {
resources.formatDurationShort(delay)
} else {
null
}
if (formattedTime != null) {
resources.getString(R.string.too_many_requests_message_retry, formattedTime)
} else {
resources.getString(R.string.too_many_requests_message)
}
}
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)
is FileNotFoundException -> parseMessage(resources) ?: message
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
is SyncApiException,
is ContentUnavailableException -> message
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)
is FileNotFoundException -> parseMessage(resources) ?: message
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
is EmptyMangaException -> reason?.let { resources.getString(it.msgResId) } ?: cause?.getDisplayMessage(resources)
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
is SyncApiException,
is ContentUnavailableException -> message
is ParseException -> shortMessage
is ConnectException,
is UnknownHostException,
is NoRouteToHostException,
is SocketTimeoutException -> resources.getString(R.string.network_error)
is ParseException -> shortMessage
is ConnectException,
is UnknownHostException,
is NoRouteToHostException,
is SocketTimeoutException -> resources.getString(R.string.network_error)
is ImageDecodeException -> {
val type = format?.substringBefore('/')
val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) }
if (type.isNullOrEmpty() || type == "image") {
resources.getString(R.string.error_image_format, formatString)
} else {
resources.getString(R.string.error_not_image, formatString)
}
}
is ImageDecodeException -> {
val type = format?.substringBefore('/')
val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) }
if (type.isNullOrEmpty() || type == "image") {
resources.getString(R.string.error_image_format, formatString)
} else {
resources.getString(R.string.error_not_image, formatString)
}
}
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
is IncompatiblePluginException -> {
cause?.getDisplayMessageOrNull(resources)?.let {
resources.getString(R.string.plugin_incompatible_with_cause, it)
} ?: resources.getString(R.string.plugin_incompatible)
}
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
is IncompatiblePluginException -> {
cause?.getDisplayMessageOrNull(resources)?.let {
resources.getString(R.string.plugin_incompatible_with_cause, it)
} ?: resources.getString(R.string.plugin_incompatible)
}
is WrongPasswordException -> resources.getString(R.string.wrong_password)
is NotFoundException -> resources.getString(R.string.not_found_404)
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
is WrongPasswordException -> resources.getString(R.string.wrong_password)
is NotFoundException -> resources.getString(R.string.not_found_404)
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
is HttpException -> getHttpDisplayMessage(response.code, resources)
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
is HttpException -> getHttpDisplayMessage(response.code, resources)
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
else -> mapDisplayMessage(message, resources) ?: message
else -> mapDisplayMessage(message, resources) ?: message
}.takeUnless { it.isNullOrBlank() }
@DrawableRes
fun Throwable.getDisplayIcon(): Int = when (this) {
is AuthRequiredException -> R.drawable.ic_auth_key_large
is CloudFlareProtectedException -> R.drawable.ic_bot_large
is UnknownHostException,
is SocketTimeoutException,
is ConnectException,
is NoRouteToHostException,
is ProtocolException -> R.drawable.ic_plug_large
is AuthRequiredException -> R.drawable.ic_auth_key_large
is CloudFlareProtectedException -> R.drawable.ic_bot_large
is UnknownHostException,
is SocketTimeoutException,
is ConnectException,
is NoRouteToHostException,
is ProtocolException -> R.drawable.ic_plug_large
is CloudFlareBlockedException -> R.drawable.ic_denied_large
is CloudFlareBlockedException -> R.drawable.ic_denied_large
is InteractiveActionRequiredException -> R.drawable.ic_interaction_large
else -> R.drawable.ic_error_large
is InteractiveActionRequiredException -> R.drawable.ic_interaction_large
else -> R.drawable.ic_error_large
}
fun Throwable.getCauseUrl(): String? = when (this) {
is ParseException -> url
is NotFoundException -> url
is TooManyRequestExceptions -> url
is CaughtException -> cause.getCauseUrl()
is WrapperIOException -> cause.getCauseUrl()
is NoDataReceivedException -> url
is CloudFlareBlockedException -> url
is CloudFlareProtectedException -> url
is InteractiveActionRequiredException -> url
is HttpStatusException -> url
is HttpException -> (response.delegate as? Response)?.request?.url?.toString()
else -> null
is ParseException -> url
is NotFoundException -> url
is TooManyRequestExceptions -> url
is CaughtException -> cause.getCauseUrl()
is WrapperIOException -> cause.getCauseUrl()
is NoDataReceivedException -> url
is CloudFlareBlockedException -> url
is CloudFlareProtectedException -> url
is InteractiveActionRequiredException -> url
is HttpStatusException -> url
is UnsupportedSourceException -> manga?.publicUrl?.takeIf { it.isHttpUrl() }
is EmptyMangaException -> manga.publicUrl.takeIf { it.isHttpUrl() }
is HttpException -> (response.delegate as? Response)?.request?.url?.toString()
else -> null
}
private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) {
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
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
}
private fun mapDisplayMessage(msg: String?, resources: Resources): String? = when {
msg.isNullOrEmpty() -> null
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
msg == MSG_CONNECTION_RESET -> resources.getString(R.string.error_connection_reset)
msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported)
msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported)
else -> null
msg.isNullOrEmpty() -> null
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
msg == MSG_CONNECTION_RESET -> resources.getString(R.string.error_connection_reset)
msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported)
msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported)
else -> null
}
fun Throwable.isReportable(): Boolean {
if (this is Error) {
return true
}
if (this is CaughtException) {
return cause.isReportable()
}
if (this is WrapperIOException) {
return cause.isReportable()
}
if (ExceptionResolver.canResolve(this)) {
return false
}
if (this is ParseException
|| this.isNetworkError()
|| this is CloudFlareBlockedException
|| this is CloudFlareProtectedException
|| this is BadBackupFormatException
|| this is WrongPasswordException
|| this is TooManyRequestExceptions
|| this is HttpStatusException
) {
return false
}
return true
if (this is Error) {
return true
}
if (this is CaughtException) {
return cause.isReportable()
}
if (this is WrapperIOException) {
return cause.isReportable()
}
if (ExceptionResolver.canResolve(this)) {
return false
}
if (this is ParseException
|| this.isNetworkError()
|| this is CloudFlareBlockedException
|| this is CloudFlareProtectedException
|| this is BadBackupFormatException
|| this is WrongPasswordException
|| this is TooManyRequestExceptions
|| this is HttpStatusException
) {
return false
}
return true
}
fun Throwable.isNetworkError(): Boolean {
return this is UnknownHostException
|| this is SocketTimeoutException
|| this is StreamResetException
|| this is SocketException
|| this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT
return this is UnknownHostException
|| this is SocketTimeoutException
|| this is StreamResetException
|| this is SocketException
|| this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT
}
fun Throwable.report(silent: Boolean = false) {
val exception = CaughtException(this)
if (!silent) {
exception.sendWithAcra()
} else if (!BuildConfig.DEBUG) {
exception.sendSilentlyWithAcra()
}
val exception = CaughtException(this)
if (!silent) {
exception.sendWithAcra()
} else if (!BuildConfig.DEBUG) {
exception.sendSilentlyWithAcra()
}
}
fun Throwable.isWebViewUnavailable(): Boolean {
val trace = stackTraceToString()
return trace.contains("android.webkit.WebView.<init>")
val trace = stackTraceToString()
return trace.contains("android.webkit.WebView.<init>")
}
@Suppress("FunctionName")
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)
fun FileNotFoundException.getFile(): File? {
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
return groups.getOrNull(1)?.let { File(it) }
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
return groups.getOrNull(1)?.let { File(it) }
}
fun FileNotFoundException.parseMessage(resources: Resources): String? {
/*
Examples:
/storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system)
/storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory)
/storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error)
*/
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
val path = groups.getOrNull(1)
val error = groups.getOrNull(2)
val baseMessageIs = when (error) {
"EROFS" -> R.string.no_write_permission_to_file
"ENOENT" -> R.string.file_not_found
else -> return null
}
return if (path.isNullOrEmpty()) {
resources.getString(baseMessageIs)
} else {
resources.getString(
R.string.inline_preference_pattern,
resources.getString(baseMessageIs),
path,
)
}
/*
Examples:
/storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system)
/storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory)
/storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error)
*/
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
val path = groups.getOrNull(1)
val error = groups.getOrNull(2)
val baseMessageIs = when (error) {
"EROFS" -> R.string.no_write_permission_to_file
"ENOENT" -> R.string.file_not_found
else -> return null
}
return if (path.isNullOrEmpty()) {
resources.getString(baseMessageIs)
} else {
resources.getString(
R.string.inline_preference_pattern,
resources.getString(baseMessageIs),
path,
)
}
}

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

View File

@@ -7,111 +7,115 @@ import org.koitharu.kotatsu.core.ui.model.MangaOverride
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.reader.data.filterChapters
import java.util.Locale
data class MangaDetails(
private val manga: Manga,
private val localManga: LocalManga?,
private val override: MangaOverride?,
val description: CharSequence?,
val isLoaded: Boolean,
private val manga: Manga,
private val localManga: LocalManga?,
private val override: MangaOverride?,
val description: CharSequence?,
val isLoaded: Boolean,
) {
constructor(manga: Manga) : this(
manga = manga,
localManga = null,
override = null,
description = null,
isLoaded = false,
)
constructor(manga: Manga) : this(
manga = manga,
localManga = null,
override = null,
description = null,
isLoaded = false,
)
val id: Long
get() = manga.id
val id: Long
get() = manga.id
val allChapters: List<MangaChapter> by lazy { mergeChapters() }
val allChapters: List<MangaChapter> by lazy { mergeChapters() }
val chapters: Map<String?, List<MangaChapter>> by lazy {
allChapters.groupBy { it.branch }
}
val chapters: Map<String?, List<MangaChapter>> by lazy {
allChapters.groupBy { it.branch }
}
val isLocal
get() = manga.isLocal
val isLocal
get() = manga.isLocal
val local: LocalManga?
get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null
val local: LocalManga?
get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null
val coverUrl: String?
get() = override?.coverUrl
.ifNullOrEmpty { manga.largeCoverUrl }
.ifNullOrEmpty { manga.coverUrl }
.ifNullOrEmpty { localManga?.manga?.coverUrl }
?.nullIfEmpty()
val coverUrl: String?
get() = override?.coverUrl
.ifNullOrEmpty { manga.largeCoverUrl }
.ifNullOrEmpty { manga.coverUrl }
.ifNullOrEmpty { localManga?.manga?.coverUrl }
?.nullIfEmpty()
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,
)
}
}
val isRestricted: Boolean
get() = manga.state == MangaState.RESTRICTED
fun toManga() = mergedManga
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 getLocale(): Locale? {
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
return it
}
return manga.source.getLocale()
}
fun toManga() = mergedManga
fun filterChapters(branch: String?) = copy(
manga = manga.filterChapters(branch),
localManga = localManga?.run {
copy(manga = manga.filterChapters(branch))
},
)
fun getLocale(): Locale? {
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
return it
}
return manga.source.getLocale()
}
private fun mergeChapters(): List<MangaChapter> {
val chapters = manga.chapters
val localChapters = local?.manga?.chapters.orEmpty()
if (chapters.isNullOrEmpty()) {
return localChapters
}
val localMap = if (localChapters.isNotEmpty()) {
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
} else {
null
}
val result = ArrayList<MangaChapter>(chapters.size)
for (chapter in chapters) {
val local = localMap?.remove(chapter.id)
result += local ?: chapter
}
if (!localMap.isNullOrEmpty()) {
result.addAll(localMap.values)
}
return result
}
fun filterChapters(branch: String?) = copy(
manga = manga.filterChapters(branch),
localManga = localManga?.run {
copy(manga = manga.filterChapters(branch))
},
)
private fun findAppropriateLocale(name: String?): Locale? {
if (name.isNullOrEmpty()) {
return null
}
return Locale.getAvailableLocales().find { lc ->
name.contains(lc.getDisplayName(lc), ignoreCase = true) ||
name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) ||
name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) ||
name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true)
}
}
private fun mergeChapters(): List<MangaChapter> {
val chapters = manga.chapters
val localChapters = local?.manga?.chapters.orEmpty()
if (chapters.isNullOrEmpty()) {
return localChapters
}
val localMap = if (localChapters.isNotEmpty()) {
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
} else {
null
}
val result = ArrayList<MangaChapter>(chapters.size)
for (chapter in chapters) {
val local = localMap?.remove(chapter.id)
result += local ?: chapter
}
if (!localMap.isNullOrEmpty()) {
result.addAll(localMap.values)
}
return result
}
private fun findAppropriateLocale(name: String?): Locale? {
if (name.isNullOrEmpty()) {
return null
}
return Locale.getAvailableLocales().find { lc ->
name.contains(lc.getDisplayName(lc), ignoreCase = true) ||
name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) ||
name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) ||
name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true)
}
}
}

View File

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

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

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

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 {

View File

@@ -99,10 +99,11 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(),
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets
override fun onStateChanged(sheet: View, newState: Int) {
if (newState == STATE_DRAGGING || newState == STATE_SETTLING) {
return
}
val binding = viewBinding ?: return
val binding = viewBinding ?: return
binding.layoutTouchBlock.isTouchEventsAllowed = dialog != null || newState != STATE_COLLAPSED
if (newState == STATE_DRAGGING || newState == STATE_SETTLING) {
return
}
val isActionModeStarted = actionModeDelegate?.isActionModeStarted == true
binding.toolbar.menuView?.isVisible = newState == STATE_EXPANDED && !isActionModeStarted
binding.splitButtonRead.isVisible = newState != STATE_EXPANDED && !isActionModeStarted

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

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

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

View File

@@ -53,11 +53,9 @@ 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.noneOf<MangaParserSource>(MangaParserSource::class.java).also {
MangaParserSource.entries.filterNotTo(it, MangaParserSource::isBroken)
}
)
suspend fun getEnabledSources(): List<MangaSource> {

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

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

@@ -0,0 +1,161 @@
package org.koitharu.kotatsu.filter.data
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.SetSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.descriptors.element
import kotlinx.serialization.encoding.CompositeDecoder
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.encoding.decodeStructure
import kotlinx.serialization.encoding.encodeStructure
import kotlinx.serialization.serializer
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.util.ext.toLocaleOrNull
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Demographic
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import java.util.Locale
object MangaListFilterSerializer : KSerializer<MangaListFilter> {
override val descriptor: SerialDescriptor =
buildClassSerialDescriptor(MangaListFilter::class.java.name) {
element<String?>("query", isOptional = true)
element(
elementName = "tags",
descriptor = SetSerializer(MangaTagSerializer).descriptor,
isOptional = true,
)
element(
elementName = "tagsExclude",
descriptor = SetSerializer(MangaTagSerializer).descriptor,
isOptional = true,
)
element<String?>("locale", isOptional = true)
element<String?>("originalLocale", isOptional = true)
element<Set<MangaState>>("states", isOptional = true)
element<Set<ContentRating>>("contentRating", isOptional = true)
element<Set<ContentType>>("types", isOptional = true)
element<Set<Demographic>>("demographics", isOptional = true)
element<Int>("year", isOptional = true)
element<Int>("yearFrom", isOptional = true)
element<Int>("yearTo", isOptional = true)
element<String?>("author", isOptional = true)
}
override fun serialize(
encoder: Encoder,
value: MangaListFilter
) = encoder.encodeStructure(descriptor) {
encodeNullableSerializableElement(descriptor, 0, String.serializer(), value.query)
encodeSerializableElement(descriptor, 1, SetSerializer(MangaTagSerializer), value.tags)
encodeSerializableElement(descriptor, 2, SetSerializer(MangaTagSerializer), value.tagsExclude)
encodeNullableSerializableElement(descriptor, 3, String.serializer(), value.locale?.toLanguageTag())
encodeNullableSerializableElement(descriptor, 4, String.serializer(), value.originalLocale?.toLanguageTag())
encodeSerializableElement(descriptor, 5, SetSerializer(serializer()), value.states)
encodeSerializableElement(descriptor, 6, SetSerializer(serializer()), value.contentRating)
encodeSerializableElement(descriptor, 7, SetSerializer(serializer()), value.types)
encodeSerializableElement(descriptor, 8, SetSerializer(serializer()), value.demographics)
encodeIntElement(descriptor, 9, value.year)
encodeIntElement(descriptor, 10, value.yearFrom)
encodeIntElement(descriptor, 11, value.yearTo)
encodeNullableSerializableElement(descriptor, 12, String.serializer(), value.author)
}
override fun deserialize(
decoder: Decoder
): MangaListFilter = decoder.decodeStructure(descriptor) {
var query: String? = MangaListFilter.EMPTY.query
var tags: Set<MangaTag> = MangaListFilter.EMPTY.tags
var tagsExclude: Set<MangaTag> = MangaListFilter.EMPTY.tagsExclude
var locale: Locale? = MangaListFilter.EMPTY.locale
var originalLocale: Locale? = MangaListFilter.EMPTY.originalLocale
var states: Set<MangaState> = MangaListFilter.EMPTY.states
var contentRating: Set<ContentRating> = MangaListFilter.EMPTY.contentRating
var types: Set<ContentType> = MangaListFilter.EMPTY.types
var demographics: Set<Demographic> = MangaListFilter.EMPTY.demographics
var year: Int = MangaListFilter.EMPTY.year
var yearFrom: Int = MangaListFilter.EMPTY.yearFrom
var yearTo: Int = MangaListFilter.EMPTY.yearTo
var author: String? = MangaListFilter.EMPTY.author
while (true) {
when (decodeElementIndex(descriptor)) {
0 -> query = decodeNullableSerializableElement(descriptor, 0, serializer<String>())
1 -> tags = decodeSerializableElement(descriptor, 1, SetSerializer(MangaTagSerializer))
2 -> tagsExclude = decodeSerializableElement(descriptor, 2, SetSerializer(MangaTagSerializer))
3 -> locale = decodeNullableSerializableElement(descriptor, 3, serializer<String>())?.toLocaleOrNull()
4 -> originalLocale =
decodeNullableSerializableElement(descriptor, 4, serializer<String>())?.toLocaleOrNull()
5 -> states = decodeSerializableElement(descriptor, 5, SetSerializer(serializer()))
6 -> contentRating = decodeSerializableElement(descriptor, 6, SetSerializer(serializer()))
7 -> types = decodeSerializableElement(descriptor, 7, SetSerializer(serializer()))
8 -> demographics = decodeSerializableElement(descriptor, 8, SetSerializer(serializer()))
9 -> year = decodeIntElement(descriptor, 9)
10 -> yearFrom = decodeIntElement(descriptor, 10)
11 -> yearTo = decodeIntElement(descriptor, 11)
12 -> author = decodeNullableSerializableElement(descriptor, 12, serializer<String>())
CompositeDecoder.DECODE_DONE -> break
}
}
MangaListFilter(
query = query,
tags = tags,
tagsExclude = tagsExclude,
locale = locale,
originalLocale = originalLocale,
states = states,
contentRating = contentRating,
types = types,
demographics = demographics,
year = year,
yearFrom = yearFrom,
yearTo = yearTo,
author = author,
)
}
private object MangaTagSerializer : KSerializer<MangaTag> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor(MangaTag::class.java.name) {
element<String>("title")
element<String>("key")
element<String>("source")
}
override fun serialize(encoder: Encoder, value: MangaTag) = encoder.encodeStructure(descriptor) {
encodeStringElement(descriptor, 0, value.title)
encodeStringElement(descriptor, 1, value.key)
encodeStringElement(descriptor, 2, value.source.name)
}
override fun deserialize(decoder: Decoder): MangaTag = decoder.decodeStructure(descriptor) {
var title: String? = null
var key: String? = null
var source: String? = null
while (true) {
when (decodeElementIndex(descriptor)) {
0 -> title = decodeStringElement(descriptor, 0)
1 -> key = decodeStringElement(descriptor, 1)
2 -> source = decodeStringElement(descriptor, 2)
CompositeDecoder.DECODE_DONE -> break
}
}
MangaTag(
title = title ?: error("Missing 'title' field"),
key = key ?: error("Missing 'key' field"),
source = MangaSource(source),
)
}
}
}

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.filter.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
import org.koitharu.kotatsu.core.model.MangaSourceSerializer
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
@Serializable
@JsonIgnoreUnknownKeys
data class PersistableFilter(
@SerialName("name")
val name: String,
@Serializable(with = MangaSourceSerializer::class)
@SerialName("source")
val source: MangaSource,
@Serializable(with = MangaListFilterSerializer::class)
@SerialName("filter")
val filter: MangaListFilter,
) {
val id: Int
get() = name.hashCode()
companion object {
const val MAX_TITLE_LENGTH = 18
}
}

View File

@@ -0,0 +1,118 @@
package org.koitharu.kotatsu.filter.data
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.koitharu.kotatsu.core.util.ext.observeChanges
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.io.File
import javax.inject.Inject
@Reusable
class SavedFiltersRepository @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun observeAll(source: MangaSource): Flow<List<PersistableFilter>> = getPrefs(source).observeChanges()
.onStart { emit(null) }
.map {
getAll(source)
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
suspend fun getAll(source: MangaSource): List<PersistableFilter> = withContext(Dispatchers.Default) {
val prefs = getPrefs(source)
val keys = prefs.all.keys.filter { it.startsWith(FILTER_PREFIX) }
keys.mapNotNull { key ->
val value = prefs.getString(key, null) ?: return@mapNotNull null
try {
Json.decodeFromString(value)
} catch (e: SerializationException) {
e.printStackTraceDebug()
null
}
}
}
suspend fun save(
source: MangaSource,
name: String,
filter: MangaListFilter,
): PersistableFilter = withContext(Dispatchers.Default) {
val persistableFilter = PersistableFilter(
name = name,
source = source,
filter = filter,
)
persist(persistableFilter)
persistableFilter
}
suspend fun save(
filter: PersistableFilter,
) = withContext(Dispatchers.Default) {
persist(filter)
}
suspend fun rename(source: MangaSource, id: Int, newName: String) = withContext(Dispatchers.Default) {
val filter = load(source, id) ?: return@withContext
val newFilter = filter.copy(name = newName)
val prefs = getPrefs(source)
prefs.edit(commit = true) {
remove(key(id))
putString(key(newFilter.id), Json.encodeToString(newFilter))
}
newFilter
}
suspend fun delete(source: MangaSource, id: Int) = withContext(Dispatchers.Default) {
val prefs = getPrefs(source)
prefs.edit(commit = true) {
remove(key(id))
}
}
private fun persist(persistableFilter: PersistableFilter) {
val prefs = getPrefs(persistableFilter.source)
val json = Json.encodeToString(persistableFilter)
prefs.edit(commit = true) {
putString(key(persistableFilter.id), json)
}
}
private fun load(source: MangaSource, id: Int): PersistableFilter? {
val prefs = getPrefs(source)
val json = prefs.getString(key(id), null) ?: return null
return try {
Json.decodeFromString<PersistableFilter>(json)
} catch (e: SerializationException) {
e.printStackTraceDebug()
null
}
}
private fun getPrefs(source: MangaSource): SharedPreferences {
val key = source.name.replace(File.separatorChar, '$')
return context.getSharedPreferences(key, Context.MODE_PRIVATE)
}
private companion object {
const val FILTER_PREFIX = "__pf_"
fun key(id: Int) = FILTER_PREFIX + id
}
}

View File

@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -25,6 +26,8 @@ import org.koitharu.kotatsu.core.util.ext.asFlow
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
import org.koitharu.kotatsu.filter.data.PersistableFilter
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.filter.ui.tags.TagTitleComparator
import org.koitharu.kotatsu.parsers.model.ContentRating
@@ -48,469 +51,502 @@ import javax.inject.Inject
@ViewModelScoped
class FilterCoordinator @Inject constructor(
savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory,
private val searchRepository: MangaSearchRepository,
lifecycle: ViewModelLifecycle,
savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory,
private val searchRepository: MangaSearchRepository,
private val savedFiltersRepository: SavedFiltersRepository,
lifecycle: ViewModelLifecycle,
) {
private val coroutineScope = lifecycle.lifecycleScope + Dispatchers.Default
private val repository = mangaRepositoryFactory.create(MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE]))
private val sourceLocale = (repository.source as? MangaParserSource)?.locale
private val coroutineScope = lifecycle.lifecycleScope + Dispatchers.Default
private val repository = mangaRepositoryFactory.create(MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE]))
private val sourceLocale = (repository.source as? MangaParserSource)?.locale
private val currentListFilter = MutableStateFlow(MangaListFilter.EMPTY)
private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder)
private val currentListFilter = MutableStateFlow(MangaListFilter.EMPTY)
private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder)
private val availableSortOrders = repository.sortOrders
private val filterOptions = suspendLazy { repository.getFilterOptions() }
val capabilities = repository.filterCapabilities
private val availableSortOrders = repository.sortOrders
private val filterOptions = suspendLazy { repository.getFilterOptions() }
val mangaSource: MangaSource
get() = repository.source
val capabilities = repository.filterCapabilities
val isFilterApplied: Boolean
get() = currentListFilter.value.isNotEmpty()
val mangaSource: MangaSource
get() = repository.source
val query: StateFlow<String?> = currentListFilter.map { it.query }
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
val isFilterApplied: Boolean
get() = currentListFilter.value.isNotEmpty()
val sortOrder: StateFlow<FilterProperty<SortOrder>> = currentSortOrder.map { selected ->
FilterProperty(
availableItems = availableSortOrders.sortedByOrdinal(),
selectedItem = selected,
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val query: StateFlow<String?> = currentListFilter.map { it.query }
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
val tags: StateFlow<FilterProperty<MangaTag>> = combine(
getTopTags(TAGS_LIMIT),
currentListFilter.distinctUntilChangedBy { it.tags },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.addFirstDistinct(selected.tags),
selectedItems = selected.tags,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val sortOrder: StateFlow<FilterProperty<SortOrder>> = currentSortOrder.map { selected ->
FilterProperty(
availableItems = availableSortOrders.sortedByOrdinal(),
selectedItem = selected,
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val tagsExcluded: StateFlow<FilterProperty<MangaTag>> = if (capabilities.isTagsExclusionSupported) {
combine(
getBottomTags(TAGS_LIMIT),
currentListFilter.distinctUntilChangedBy { it.tagsExclude },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.addFirstDistinct(selected.tagsExclude),
selectedItems = selected.tagsExclude,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
val tags: StateFlow<FilterProperty<MangaTag>> = combine(
getTopTags(TAGS_LIMIT),
currentListFilter.distinctUntilChangedBy { it.tags },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.addFirstDistinct(selected.tags),
selectedItems = selected.tags,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val states: StateFlow<FilterProperty<MangaState>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.states },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableStates.sortedByOrdinal(),
selectedItems = selected.states,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val tagsExcluded: StateFlow<FilterProperty<MangaTag>> = if (capabilities.isTagsExclusionSupported) {
combine(
getBottomTags(TAGS_LIMIT),
currentListFilter.distinctUntilChangedBy { it.tagsExclude },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.addFirstDistinct(selected.tagsExclude),
selectedItems = selected.tagsExclude,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
val contentRating: StateFlow<FilterProperty<ContentRating>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.contentRating },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableContentRating.sortedByOrdinal(),
selectedItems = selected.contentRating,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val authors: StateFlow<FilterProperty<String>> = if (capabilities.isAuthorSearchSupported) {
combine(
flow { emit(searchRepository.getAuthors(repository.source, TAGS_LIMIT)) },
currentListFilter.distinctUntilChangedBy { it.author },
) { available, selected ->
FilterProperty(
availableItems = available,
selectedItems = setOfNotNull(selected.author),
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
val contentTypes: StateFlow<FilterProperty<ContentType>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.types },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableContentTypes.sortedByOrdinal(),
selectedItems = selected.types,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val states: StateFlow<FilterProperty<MangaState>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.states },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableStates.sortedByOrdinal(),
selectedItems = selected.states,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val demographics: StateFlow<FilterProperty<Demographic>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.demographics },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableDemographics.sortedByOrdinal(),
selectedItems = selected.demographics,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val contentRating: StateFlow<FilterProperty<ContentRating>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.contentRating },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableContentRating.sortedByOrdinal(),
selectedItems = selected.contentRating,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val locale: StateFlow<FilterProperty<Locale?>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.locale },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null),
selectedItems = setOfNotNull(selected.locale),
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val contentTypes: StateFlow<FilterProperty<ContentType>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.types },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableContentTypes.sortedByOrdinal(),
selectedItems = selected.types,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val originalLocale: StateFlow<FilterProperty<Locale?>> = if (capabilities.isOriginalLocaleSupported) {
combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.originalLocale },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null),
selectedItems = setOfNotNull(selected.originalLocale),
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
val demographics: StateFlow<FilterProperty<Demographic>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.demographics },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableDemographics.sortedByOrdinal(),
selectedItems = selected.demographics,
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val year: StateFlow<FilterProperty<Int>> = if (capabilities.isYearSupported) {
currentListFilter.distinctUntilChangedBy { it.year }.map { selected ->
FilterProperty(
availableItems = listOf(YEAR_MIN, MAX_YEAR),
selectedItems = setOf(selected.year),
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
val locale: StateFlow<FilterProperty<Locale?>> = combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.locale },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null),
selectedItems = setOfNotNull(selected.locale),
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
val yearRange: StateFlow<FilterProperty<Int>> = if (capabilities.isYearRangeSupported) {
currentListFilter.distinctUntilChanged { old, new ->
old.yearTo == new.yearTo && old.yearFrom == new.yearFrom
}.map { selected ->
FilterProperty(
availableItems = listOf(YEAR_MIN, MAX_YEAR),
selectedItems = setOf(selected.yearFrom.ifZero { YEAR_MIN }, selected.yearTo.ifZero { MAX_YEAR }),
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
val originalLocale: StateFlow<FilterProperty<Locale?>> = if (capabilities.isOriginalLocaleSupported) {
combine(
filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.originalLocale },
) { available, selected ->
available.fold(
onSuccess = {
FilterProperty(
availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null),
selectedItems = setOfNotNull(selected.originalLocale),
)
},
onFailure = {
FilterProperty.error(it)
},
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
fun reset() {
currentListFilter.value = MangaListFilter.EMPTY
}
val year: StateFlow<FilterProperty<Int>> = if (capabilities.isYearSupported) {
currentListFilter.distinctUntilChangedBy { it.year }.map { selected ->
FilterProperty(
availableItems = listOf(YEAR_MIN, MAX_YEAR),
selectedItems = setOf(selected.year),
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
fun snapshot() = Snapshot(
sortOrder = currentSortOrder.value,
listFilter = currentListFilter.value,
)
val yearRange: StateFlow<FilterProperty<Int>> = if (capabilities.isYearRangeSupported) {
currentListFilter.distinctUntilChanged { old, new ->
old.yearTo == new.yearTo && old.yearFrom == new.yearFrom
}.map { selected ->
FilterProperty(
availableItems = listOf(YEAR_MIN, MAX_YEAR),
selectedItems = setOf(selected.yearFrom.ifZero { YEAR_MIN }, selected.yearTo.ifZero { MAX_YEAR }),
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
fun observe(): Flow<Snapshot> = combine(currentSortOrder, currentListFilter, ::Snapshot)
val savedFilters: StateFlow<FilterProperty<PersistableFilter>> = combine(
savedFiltersRepository.observeAll(repository.source),
currentListFilter,
) { available, applied ->
FilterProperty(
availableItems = available,
selectedItems = setOfNotNull(available.find { it.filter == applied }),
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.EMPTY)
fun setSortOrder(newSortOrder: SortOrder) {
currentSortOrder.value = newSortOrder
repository.defaultSortOrder = newSortOrder
}
fun reset() {
currentListFilter.value = MangaListFilter.EMPTY
}
fun set(value: MangaListFilter) {
currentListFilter.value = value
}
fun snapshot() = Snapshot(
sortOrder = currentSortOrder.value,
listFilter = currentListFilter.value,
)
fun setAdjusted(value: MangaListFilter) {
var newFilter = value
if (!newFilter.author.isNullOrEmpty() && !capabilities.isAuthorSearchSupported) {
newFilter = newFilter.copy(
query = newFilter.author,
author = null,
)
}
if (!capabilities.isSearchSupported && !newFilter.query.isNullOrEmpty()) {
newFilter = newFilter.copy(
query = null,
)
}
if (!newFilter.query.isNullOrEmpty() && !newFilter.hasNonSearchOptions() && !capabilities.isSearchWithFiltersSupported) {
newFilter = MangaListFilter(query = newFilter.query)
}
set(newFilter)
}
fun observe(): Flow<Snapshot> = combine(currentSortOrder, currentListFilter, ::Snapshot)
fun setQuery(value: String?) {
val newQuery = value?.trim()?.nullIfEmpty()
currentListFilter.update { oldValue ->
if (capabilities.isSearchWithFiltersSupported || newQuery == null) {
oldValue.copy(query = newQuery)
} else {
MangaListFilter(query = newQuery)
}
}
}
fun setSortOrder(newSortOrder: SortOrder) {
currentSortOrder.value = newSortOrder
repository.defaultSortOrder = newSortOrder
}
fun setLocale(value: Locale?) {
currentListFilter.update { oldValue ->
oldValue.copy(
locale = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun set(value: MangaListFilter) {
currentListFilter.value = value
}
fun setAuthor(value: String?) {
currentListFilter.update { oldValue ->
oldValue.copy(
author = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setAdjusted(value: MangaListFilter) {
var newFilter = value
if (!newFilter.author.isNullOrEmpty() && !capabilities.isAuthorSearchSupported) {
newFilter = newFilter.copy(
query = newFilter.author,
author = null,
)
}
if (!newFilter.query.isNullOrEmpty() && !newFilter.hasNonSearchOptions() && !capabilities.isSearchWithFiltersSupported) {
newFilter = MangaListFilter(query = newFilter.query)
}
set(newFilter)
}
fun setOriginalLocale(value: Locale?) {
currentListFilter.update { oldValue ->
oldValue.copy(
originalLocale = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun saveCurrentFilter(name: String) = coroutineScope.launch {
savedFiltersRepository.save(repository.source, name, currentListFilter.value)
}
fun setYear(value: Int) {
currentListFilter.update { oldValue ->
oldValue.copy(
year = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun renameSavedFilter(id: Int, newName: String) = coroutineScope.launch {
savedFiltersRepository.rename(repository.source, id, newName)
}
fun setYearRange(valueFrom: Int, valueTo: Int) {
currentListFilter.update { oldValue ->
oldValue.copy(
yearFrom = valueFrom,
yearTo = valueTo,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun deleteSavedFilter(id: Int) = coroutineScope.launch {
savedFiltersRepository.delete(repository.source, id)
}
fun toggleState(value: MangaState, isSelected: Boolean) {
currentListFilter.update { oldValue ->
oldValue.copy(
states = if (isSelected) oldValue.states + value else oldValue.states - value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setQuery(value: String?) {
val newQuery = value?.trim()?.nullIfEmpty()
currentListFilter.update { oldValue ->
if (capabilities.isSearchWithFiltersSupported || newQuery == null) {
oldValue.copy(query = newQuery)
} else {
MangaListFilter(query = newQuery)
}
}
}
fun toggleContentRating(value: ContentRating, isSelected: Boolean) {
currentListFilter.update { oldValue ->
oldValue.copy(
contentRating = if (isSelected) oldValue.contentRating + value else oldValue.contentRating - value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setLocale(value: Locale?) {
currentListFilter.update { oldValue ->
oldValue.copy(
locale = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun toggleDemographic(value: Demographic, isSelected: Boolean) {
currentListFilter.update { oldValue ->
oldValue.copy(
demographics = if (isSelected) oldValue.demographics + value else oldValue.demographics - value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setAuthor(value: String?) {
currentListFilter.update { oldValue ->
oldValue.copy(
author = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun toggleContentType(value: ContentType, isSelected: Boolean) {
currentListFilter.update { oldValue ->
oldValue.copy(
types = if (isSelected) oldValue.types + value else oldValue.types - value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setOriginalLocale(value: Locale?) {
currentListFilter.update { oldValue ->
oldValue.copy(
originalLocale = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun toggleTag(value: MangaTag, isSelected: Boolean) {
currentListFilter.update { oldValue ->
val newTags = if (capabilities.isMultipleTagsSupported) {
if (isSelected) oldValue.tags + value else oldValue.tags - value
} else {
if (isSelected) setOf(value) else emptySet()
}
oldValue.copy(
tags = newTags,
tagsExclude = oldValue.tagsExclude - newTags,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setYear(value: Int) {
currentListFilter.update { oldValue ->
oldValue.copy(
year = value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun toggleTagExclude(value: MangaTag, isSelected: Boolean) {
currentListFilter.update { oldValue ->
val newTagsExclude = if (capabilities.isMultipleTagsSupported) {
if (isSelected) oldValue.tagsExclude + value else oldValue.tagsExclude - value
} else {
if (isSelected) setOf(value) else emptySet()
}
oldValue.copy(
tags = oldValue.tags - newTagsExclude,
tagsExclude = newTagsExclude,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun setYearRange(valueFrom: Int, valueTo: Int) {
currentListFilter.update { oldValue ->
oldValue.copy(
yearFrom = valueFrom,
yearTo = valueTo,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun getAllTags(): Flow<Result<List<MangaTag>>> = filterOptions.asFlow().map {
it.map { x -> x.availableTags.sortedWithSafe(TagTitleComparator(sourceLocale)) }
}
fun toggleState(value: MangaState, isSelected: Boolean) {
currentListFilter.update { oldValue ->
oldValue.copy(
states = if (isSelected) oldValue.states + value else oldValue.states - value,
query = oldValue.takeQueryIfSupported(),
)
}
}
private fun MangaListFilter.takeQueryIfSupported() = when {
capabilities.isSearchWithFiltersSupported -> query
query.isNullOrEmpty() -> query
hasNonSearchOptions() -> null
else -> query
}
fun toggleContentRating(value: ContentRating, isSelected: Boolean) {
currentListFilter.update { oldValue ->
oldValue.copy(
contentRating = if (isSelected) oldValue.contentRating + value else oldValue.contentRating - value,
query = oldValue.takeQueryIfSupported(),
)
}
}
private fun getTopTags(limit: Int): Flow<Result<List<MangaTag>>> = combine(
flow { emit(searchRepository.getTopTags(repository.source, limit)) },
filterOptions.asFlow(),
) { suggested, options ->
val all = options.getOrNull()?.availableTags.orEmpty()
val result = ArrayList<MangaTag>(limit)
result.addAll(suggested.take(limit))
if (result.size < limit) {
result.addAll(all.shuffled().take(limit - result.size))
}
if (result.isNotEmpty()) {
Result.success(result)
} else {
options.map { result }
}
}.catch {
emit(Result.failure(it))
}
fun toggleDemographic(value: Demographic, isSelected: Boolean) {
currentListFilter.update { oldValue ->
oldValue.copy(
demographics = if (isSelected) oldValue.demographics + value else oldValue.demographics - value,
query = oldValue.takeQueryIfSupported(),
)
}
}
private fun getBottomTags(limit: Int): Flow<Result<List<MangaTag>>> = combine(
flow { emit(searchRepository.getRareTags(repository.source, limit)) },
filterOptions.asFlow(),
) { suggested, options ->
val all = options.getOrNull()?.availableTags.orEmpty()
val result = ArrayList<MangaTag>(limit)
result.addAll(suggested.take(limit))
if (result.size < limit) {
result.addAll(all.shuffled().take(limit - result.size))
}
if (result.isNotEmpty()) {
Result.success(result)
} else {
options.map { result }
}
}.catch {
emit(Result.failure(it))
}
fun toggleContentType(value: ContentType, isSelected: Boolean) {
currentListFilter.update { oldValue ->
oldValue.copy(
types = if (isSelected) oldValue.types + value else oldValue.types - value,
query = oldValue.takeQueryIfSupported(),
)
}
}
private fun <T> List<T>.addFirstDistinct(other: Collection<T>): List<T> {
val result = ArrayDeque<T>(this.size + other.size)
result.addAll(this)
for (item in other) {
if (item !in result) {
result.addFirst(item)
}
}
return result
}
fun toggleTag(value: MangaTag, isSelected: Boolean) {
currentListFilter.update { oldValue ->
val newTags = if (capabilities.isMultipleTagsSupported) {
if (isSelected) oldValue.tags + value else oldValue.tags - value
} else {
if (isSelected) setOf(value) else emptySet()
}
oldValue.copy(
tags = newTags,
tagsExclude = oldValue.tagsExclude - newTags,
query = oldValue.takeQueryIfSupported(),
)
}
}
private fun <T> List<T>.addFirstDistinct(item: T): List<T> {
val result = ArrayDeque<T>(this.size + 1)
result.addAll(this)
if (item !in result) {
result.addFirst(item)
}
return result
}
fun toggleTagExclude(value: MangaTag, isSelected: Boolean) {
currentListFilter.update { oldValue ->
val newTagsExclude = if (capabilities.isMultipleTagsSupported) {
if (isSelected) oldValue.tagsExclude + value else oldValue.tagsExclude - value
} else {
if (isSelected) setOf(value) else emptySet()
}
oldValue.copy(
tags = oldValue.tags - newTagsExclude,
tagsExclude = newTagsExclude,
query = oldValue.takeQueryIfSupported(),
)
}
}
data class Snapshot(
val sortOrder: SortOrder,
val listFilter: MangaListFilter,
)
fun getAllTags(): Flow<Result<List<MangaTag>>> = filterOptions.asFlow().map {
it.map { x -> x.availableTags.sortedWithSafe(TagTitleComparator(sourceLocale)) }
}
interface Owner {
private fun MangaListFilter.takeQueryIfSupported() = when {
capabilities.isSearchWithFiltersSupported -> query
query.isNullOrEmpty() -> query
hasNonSearchOptions() -> null
else -> query
}
val filterCoordinator: FilterCoordinator
}
private fun getTopTags(limit: Int): Flow<Result<List<MangaTag>>> = combine(
flow { emit(searchRepository.getTopTags(repository.source, limit)) },
filterOptions.asFlow(),
) { suggested, options ->
val all = options.getOrNull()?.availableTags.orEmpty()
val result = ArrayList<MangaTag>(limit)
result.addAll(suggested.take(limit))
if (result.size < limit) {
result.addAll(all.shuffled().take(limit - result.size))
}
if (result.isNotEmpty()) {
Result.success(result)
} else {
options.map { result }
}
}.catch {
emit(Result.failure(it))
}
companion object {
private fun getBottomTags(limit: Int): Flow<Result<List<MangaTag>>> = combine(
flow { emit(searchRepository.getRareTags(repository.source, limit)) },
filterOptions.asFlow(),
) { suggested, options ->
val all = options.getOrNull()?.availableTags.orEmpty()
val result = ArrayList<MangaTag>(limit)
result.addAll(suggested.take(limit))
if (result.size < limit) {
result.addAll(all.shuffled().take(limit - result.size))
}
if (result.isNotEmpty()) {
Result.success(result)
} else {
options.map { result }
}
}.catch {
emit(Result.failure(it))
}
private const val TAGS_LIMIT = 12
private val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1
private fun <T> List<T>.addFirstDistinct(other: Collection<T>): List<T> {
val result = ArrayDeque<T>(this.size + other.size)
result.addAll(this)
for (item in other) {
if (item !in result) {
result.addFirst(item)
}
}
return result
}
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
}
private fun <T> List<T>.addFirstDistinct(item: T): List<T> {
val result = ArrayDeque<T>(this.size + 1)
result.addAll(this)
if (item !in result) {
result.addFirst(item)
}
return result
}
fun require(fragment: Fragment): FilterCoordinator {
return find(fragment) ?: throw IllegalStateException("FilterCoordinator cannot be found")
}
}
data class Snapshot(
val sortOrder: SortOrder,
val listFilter: MangaListFilter,
)
interface Owner {
val filterCoordinator: FilterCoordinator
}
companion object {
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")
}
}
}

View File

@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding
import org.koitharu.kotatsu.filter.data.PersistableFilter
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
@@ -28,69 +29,75 @@ import javax.inject.Inject
@AndroidEntryPoint
class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsView.OnChipClickListener,
ChipsView.OnChipCloseClickListener {
ChipsView.OnChipCloseClickListener {
@Inject
lateinit var filterHeaderProducer: FilterHeaderProducer
@Inject
lateinit var filterHeaderProducer: FilterHeaderProducer
private val filter: FilterCoordinator
get() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator
private val filter: FilterCoordinator
get() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding {
return FragmentFilterHeaderBinding.inflate(inflater, container, false)
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding {
return FragmentFilterHeaderBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
binding.chipsTags.onChipClickListener = this
binding.chipsTags.onChipCloseClickListener = this
filterHeaderProducer.observeHeader(filter)
.flowOn(Dispatchers.Default)
.observe(viewLifecycleOwner, ::onDataChanged)
}
override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
binding.chipsTags.onChipClickListener = this
binding.chipsTags.onChipCloseClickListener = this
filterHeaderProducer.observeHeader(filter)
.flowOn(Dispatchers.Default)
.observe(viewLifecycleOwner, ::onDataChanged)
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets
override fun onChipClick(chip: Chip, data: Any?) {
when (data) {
is MangaTag -> filter.toggleTag(data, !chip.isChecked)
is String -> Unit
null -> router.showTagsCatalogSheet(excludeMode = false)
}
}
override fun onChipClick(chip: Chip, data: Any?) {
when (data) {
is MangaTag -> filter.toggleTag(data, !chip.isChecked)
is PersistableFilter -> if (chip.isChecked) {
filter.reset()
} else {
filter.setAdjusted(data.filter)
}
override fun onChipCloseClick(chip: Chip, data: Any?) {
when (data) {
is String -> if (data == filter.snapshot().listFilter.author) {
filter.setAuthor(null)
} else {
filter.setQuery(null)
}
is String -> Unit
null -> router.showTagsCatalogSheet(excludeMode = false)
}
}
is ContentRating -> filter.toggleContentRating(data, false)
is Demographic -> filter.toggleDemographic(data, false)
is ContentType -> filter.toggleContentType(data, false)
is MangaState -> filter.toggleState(data, false)
is Locale -> filter.setLocale(null)
is Int -> filter.setYear(YEAR_UNKNOWN)
is IntRange -> filter.setYearRange(YEAR_UNKNOWN, YEAR_UNKNOWN)
}
}
override fun onChipCloseClick(chip: Chip, data: Any?) {
when (data) {
is String -> if (data == filter.snapshot().listFilter.author) {
filter.setAuthor(null)
} else {
filter.setQuery(null)
}
private fun onDataChanged(header: FilterHeaderModel) {
val binding = viewBinding ?: return
val chips = header.chips
if (chips.isEmpty()) {
binding.chipsTags.setChips(emptyList())
binding.root.isVisible = false
return
}
binding.chipsTags.setChips(header.chips)
binding.root.isVisible = true
if (binding.root.context.isAnimationsEnabled) {
binding.scrollView.smoothScrollTo(0, 0)
} else {
binding.scrollView.scrollTo(0, 0)
}
}
is ContentRating -> filter.toggleContentRating(data, false)
is Demographic -> filter.toggleDemographic(data, false)
is ContentType -> filter.toggleContentType(data, false)
is MangaState -> filter.toggleState(data, false)
is Locale -> filter.setLocale(null)
is Int -> filter.setYear(YEAR_UNKNOWN)
is IntRange -> filter.setYearRange(YEAR_UNKNOWN, YEAR_UNKNOWN)
}
}
private fun onDataChanged(header: FilterHeaderModel) {
val binding = viewBinding ?: return
val chips = header.chips
if (chips.isEmpty()) {
binding.chipsTags.setChips(emptyList())
binding.root.isVisible = false
return
}
binding.chipsTags.setChips(header.chips)
binding.root.isVisible = true
if (binding.root.context.isAnimationsEnabled) {
binding.scrollView.smoothScrollTo(0, 0)
} else {
binding.scrollView.scrollTo(0, 0)
}
}
}

View File

@@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.filter.data.PersistableFilter
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.parsers.model.MangaListFilter
@@ -17,143 +18,162 @@ import javax.inject.Inject
import androidx.appcompat.R as appcompatR
class FilterHeaderProducer @Inject constructor(
private val searchRepository: MangaSearchRepository,
private val searchRepository: MangaSearchRepository,
) {
fun observeHeader(filterCoordinator: FilterCoordinator): Flow<FilterHeaderModel> {
return combine(filterCoordinator.tags, filterCoordinator.observe()) { tags, snapshot ->
val chipList = createChipsList(
source = filterCoordinator.mangaSource,
capabilities = filterCoordinator.capabilities,
tagsProperty = tags,
snapshot = snapshot.listFilter,
limit = 12,
)
FilterHeaderModel(
chips = chipList,
sortOrder = snapshot.sortOrder,
isFilterApplied = !snapshot.listFilter.isEmpty(),
)
}
}
fun observeHeader(filterCoordinator: FilterCoordinator): Flow<FilterHeaderModel> {
return combine(
filterCoordinator.savedFilters,
filterCoordinator.tags,
filterCoordinator.observe(),
) { saved, tags, snapshot ->
val chipList = createChipsList(
source = filterCoordinator.mangaSource,
capabilities = filterCoordinator.capabilities,
savedFilters = saved,
tagsProperty = tags,
snapshot = snapshot.listFilter,
limit = 12,
)
FilterHeaderModel(
chips = chipList,
sortOrder = snapshot.sortOrder,
isFilterApplied = !snapshot.listFilter.isEmpty(),
)
}
}
private suspend fun createChipsList(
source: MangaSource,
capabilities: MangaListFilterCapabilities,
tagsProperty: FilterProperty<MangaTag>,
snapshot: MangaListFilter,
limit: Int,
): List<ChipsView.ChipModel> {
val result = ArrayDeque<ChipsView.ChipModel>(limit + 3)
if (snapshot.query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) {
val selectedTags = tagsProperty.selectedItems.toMutableSet()
var tags = if (selectedTags.isEmpty()) {
searchRepository.getTagsSuggestion("", limit, source)
} else {
searchRepository.getTagsSuggestion(selectedTags).take(limit)
}
if (tags.size < limit) {
tags = tags + tagsProperty.availableItems.take(limit - tags.size)
}
if (tags.isEmpty() && selectedTags.isEmpty()) {
return emptyList()
}
for (tag in tags) {
val model = ChipsView.ChipModel(
title = tag.title,
isChecked = selectedTags.remove(tag),
data = tag,
)
if (model.isChecked) {
result.addFirst(model)
} else {
result.addLast(model)
}
}
for (tag in selectedTags) {
val model = ChipsView.ChipModel(
title = tag.title,
isChecked = true,
data = tag,
)
result.addFirst(model)
}
}
snapshot.locale?.let {
result.addFirst(
ChipsView.ChipModel(
title = it.getDisplayName(it).toTitleCase(it),
icon = R.drawable.ic_language,
isCloseable = true,
data = it,
),
)
}
snapshot.types.forEach {
result.addFirst(
ChipsView.ChipModel(
titleResId = it.titleResId,
isCloseable = true,
data = it,
),
)
}
snapshot.demographics.forEach {
result.addFirst(
ChipsView.ChipModel(
titleResId = it.titleResId,
isCloseable = true,
data = it,
),
)
}
snapshot.contentRating.forEach {
result.addFirst(
ChipsView.ChipModel(
titleResId = it.titleResId,
isCloseable = true,
data = it,
),
)
}
snapshot.states.forEach {
result.addFirst(
ChipsView.ChipModel(
titleResId = it.titleResId,
isCloseable = true,
data = it,
),
)
}
if (!snapshot.query.isNullOrEmpty()) {
result.addFirst(
ChipsView.ChipModel(
title = snapshot.query,
icon = appcompatR.drawable.abc_ic_search_api_material,
isCloseable = true,
data = snapshot.query,
),
)
}
if (!snapshot.author.isNullOrEmpty()) {
result.addFirst(
ChipsView.ChipModel(
title = snapshot.author,
icon = R.drawable.ic_user,
isCloseable = true,
data = snapshot.author,
),
)
}
val hasTags = result.any { it.data is MangaTag }
if (hasTags) {
result.addFirst(moreTagsChip())
}
return result
}
private suspend fun createChipsList(
source: MangaSource,
capabilities: MangaListFilterCapabilities,
savedFilters: FilterProperty<PersistableFilter>,
tagsProperty: FilterProperty<MangaTag>,
snapshot: MangaListFilter,
limit: Int,
): List<ChipsView.ChipModel> {
val result = ArrayDeque<ChipsView.ChipModel>(savedFilters.availableItems.size + limit + 3)
if (snapshot.query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) {
val selectedTags = tagsProperty.selectedItems.toMutableSet()
var tags = if (selectedTags.isEmpty()) {
searchRepository.getTagsSuggestion("", limit, source)
} else {
searchRepository.getTagsSuggestion(selectedTags).take(limit)
}
if (tags.size < limit) {
tags = tags + tagsProperty.availableItems.take(limit - tags.size)
}
if (tags.isEmpty() && selectedTags.isEmpty()) {
return emptyList()
}
for (saved in savedFilters.availableItems) {
val model = ChipsView.ChipModel(
title = saved.name,
isChecked = saved in savedFilters.selectedItems,
data = saved,
)
if (model.isChecked) {
selectedTags.removeAll(saved.filter.tags)
result.addFirst(model)
} else {
result.addLast(model)
}
}
for (tag in tags) {
val model = ChipsView.ChipModel(
title = tag.title,
isChecked = selectedTags.remove(tag),
data = tag,
)
if (model.isChecked) {
result.addFirst(model)
} else {
result.addLast(model)
}
}
for (tag in selectedTags) {
val model = ChipsView.ChipModel(
title = tag.title,
isChecked = true,
data = tag,
)
result.addFirst(model)
}
}
snapshot.locale?.let {
result.addFirst(
ChipsView.ChipModel(
title = it.getDisplayName(it).toTitleCase(it),
icon = R.drawable.ic_language,
isCloseable = true,
data = it,
),
)
}
snapshot.types.forEach {
result.addFirst(
ChipsView.ChipModel(
titleResId = it.titleResId,
isCloseable = true,
data = it,
),
)
}
snapshot.demographics.forEach {
result.addFirst(
ChipsView.ChipModel(
titleResId = it.titleResId,
isCloseable = true,
data = it,
),
)
}
snapshot.contentRating.forEach {
result.addFirst(
ChipsView.ChipModel(
titleResId = it.titleResId,
isCloseable = true,
data = it,
),
)
}
snapshot.states.forEach {
result.addFirst(
ChipsView.ChipModel(
titleResId = it.titleResId,
isCloseable = true,
data = it,
),
)
}
if (!snapshot.query.isNullOrEmpty()) {
result.addFirst(
ChipsView.ChipModel(
title = snapshot.query,
icon = appcompatR.drawable.abc_ic_search_api_material,
isCloseable = true,
data = snapshot.query,
),
)
}
if (!snapshot.author.isNullOrEmpty()) {
result.addFirst(
ChipsView.ChipModel(
title = snapshot.author,
icon = R.drawable.ic_user,
isCloseable = true,
data = snapshot.author,
),
)
}
val hasTags = result.any { it.data is MangaTag }
if (hasTags) {
result.addFirst(moreTagsChip())
}
return result
}
private fun moreTagsChip() = ChipsView.ChipModel(
titleResId = R.string.genres,
icon = R.drawable.ic_drawer_menu_open,
)
private fun moreTagsChip() = ChipsView.ChipModel(
titleResId = R.string.genres,
icon = R.drawable.ic_drawer_menu_open,
)
}

View File

@@ -1,24 +1,39 @@
package org.koitharu.kotatsu.filter.ui.sheet
import android.os.Build
import android.os.Bundle
import android.text.InputFilter
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import com.google.android.material.chip.Chip
import com.google.android.material.slider.RangeSlider
import com.google.android.material.slider.Slider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.ui.dialog.setEditText
import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.consume
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getDisplayName
@@ -27,6 +42,8 @@ import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.ext.setValuesRounded
import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.filter.data.PersistableFilter
import org.koitharu.kotatsu.filter.data.PersistableFilter.Companion.MAX_TITLE_LENGTH
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.parsers.model.ContentRating
@@ -36,324 +53,499 @@ import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toIntUp
import java.util.Locale
import java.util.TreeSet
class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
AdapterView.OnItemSelectedListener,
ChipsView.OnChipClickListener {
AdapterView.OnItemSelectedListener,
View.OnClickListener,
ChipsView.OnChipClickListener,
ChipsView.OnChipLongClickListener,
ChipsView.OnChipCloseClickListener {
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
return SheetFilterBinding.inflate(inflater, container, false)
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
return SheetFilterBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
if (dialog == null) {
binding.layoutBody.updatePadding(top = binding.layoutBody.paddingBottom)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding.scrollView.scrollIndicators = 0
}
}
val filter = FilterCoordinator.require(this)
filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged)
filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged)
filter.tags.observe(viewLifecycleOwner, this::onTagsChanged)
filter.tagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged)
filter.states.observe(viewLifecycleOwner, this::onStateChanged)
filter.contentTypes.observe(viewLifecycleOwner, this::onContentTypesChanged)
filter.contentRating.observe(viewLifecycleOwner, this::onContentRatingChanged)
filter.demographics.observe(viewLifecycleOwner, this::onDemographicsChanged)
filter.year.observe(viewLifecycleOwner, this::onYearChanged)
filter.yearRange.observe(viewLifecycleOwner, this::onYearRangeChanged)
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
if (dialog == null) {
binding.adjustForEmbeddedLayout()
}
val filter = FilterCoordinator.require(this)
filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged)
filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged)
filter.tags.observe(viewLifecycleOwner, this::onTagsChanged)
filter.tagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged)
filter.authors.observe(viewLifecycleOwner, this::onAuthorsChanged)
filter.states.observe(viewLifecycleOwner, this::onStateChanged)
filter.contentTypes.observe(viewLifecycleOwner, this::onContentTypesChanged)
filter.contentRating.observe(viewLifecycleOwner, this::onContentRatingChanged)
filter.demographics.observe(viewLifecycleOwner, this::onDemographicsChanged)
filter.year.observe(viewLifecycleOwner, this::onYearChanged)
filter.yearRange.observe(viewLifecycleOwner, this::onYearRangeChanged)
filter.savedFilters.observe(viewLifecycleOwner, ::onSavedPresetsChanged)
binding.layoutGenres.setTitle(
if (filter.capabilities.isMultipleTagsSupported) {
R.string.genres
} else {
R.string.genre
},
)
binding.spinnerLocale.onItemSelectedListener = this
binding.spinnerOriginalLocale.onItemSelectedListener = this
binding.spinnerOrder.onItemSelectedListener = this
binding.chipsState.onChipClickListener = this
binding.chipsTypes.onChipClickListener = this
binding.chipsContentRating.onChipClickListener = this
binding.chipsDemographics.onChipClickListener = this
binding.chipsGenres.onChipClickListener = this
binding.chipsGenresExclude.onChipClickListener = this
binding.sliderYear.addOnChangeListener(this::onSliderValueChange)
binding.sliderYearsRange.addOnChangeListener(this::onRangeSliderValueChange)
binding.layoutGenres.setOnMoreButtonClickListener {
router.showTagsCatalogSheet(excludeMode = false)
}
binding.layoutGenresExclude.setOnMoreButtonClickListener {
router.showTagsCatalogSheet(excludeMode = true)
}
}
binding.layoutGenres.setTitle(
if (filter.capabilities.isMultipleTagsSupported) {
R.string.genres
} else {
R.string.genre
},
)
binding.spinnerLocale.onItemSelectedListener = this
binding.spinnerOriginalLocale.onItemSelectedListener = this
binding.spinnerOrder.onItemSelectedListener = this
binding.chipsSavedFilters.onChipClickListener = this
binding.chipsState.onChipClickListener = this
binding.chipsTypes.onChipClickListener = this
binding.chipsContentRating.onChipClickListener = this
binding.chipsDemographics.onChipClickListener = this
binding.chipsGenres.onChipClickListener = this
binding.chipsGenresExclude.onChipClickListener = this
binding.chipsAuthor.onChipClickListener = this
binding.chipsSavedFilters.onChipLongClickListener = this
binding.chipsSavedFilters.onChipCloseClickListener = this
binding.sliderYear.addOnChangeListener(this::onSliderValueChange)
binding.sliderYearsRange.addOnChangeListener(this::onRangeSliderValueChange)
binding.layoutGenres.setOnMoreButtonClickListener {
router.showTagsCatalogSheet(excludeMode = false)
}
binding.layoutGenresExclude.setOnMoreButtonClickListener {
router.showTagsCatalogSheet(excludeMode = true)
}
combine(
filter.observe().map { it.listFilter.isNotEmpty() }.distinctUntilChanged(),
filter.savedFilters.map { it.selectedItems.isEmpty() }.distinctUntilChanged(),
Boolean::and,
).flowOn(Dispatchers.Default)
.observe(viewLifecycleOwner) {
binding.buttonSave.isEnabled = it
}
binding.buttonSave.setOnClickListener(this)
binding.buttonDone.setOnClickListener(this)
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val typeMask = WindowInsetsCompat.Type.systemBars()
viewBinding?.scrollView?.updatePadding(
bottom = insets.getInsets(typeMask).bottom,
)
return insets.consume(v, typeMask, bottom = true)
}
private fun SheetFilterBinding.adjustForEmbeddedLayout() {
layoutBody.updatePadding(top = layoutBody.paddingBottom)
scrollView.scrollIndicators = 0
buttonDone.isVisible = false
this.root.layoutParams?.height = ViewGroup.LayoutParams.MATCH_PARENT
buttonSave.updateLayoutParams<LinearLayout.LayoutParams> {
weight = 0f
width = LinearLayout.LayoutParams.WRAP_CONTENT
gravity = Gravity.END or Gravity.CENTER_VERTICAL
}
}
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
val filter = FilterCoordinator.require(this)
when (parent.id) {
R.id.spinner_order -> filter.setSortOrder(filter.sortOrder.value.availableItems[position])
R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position])
R.id.spinner_original_locale -> filter.setOriginalLocale(filter.originalLocale.value.availableItems[position])
}
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val typeMask = WindowInsetsCompat.Type.systemBars()
viewBinding?.layoutBottom?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = insets.getInsets(typeMask).bottom
}
return insets.consume(v, typeMask, bottom = true)
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
override fun onClick(v: View) {
when (v.id) {
R.id.button_done -> dismiss()
R.id.button_save -> onSaveFilterClick("")
}
}
private fun onSliderValueChange(slider: Slider, value: Float, fromUser: Boolean) {
if (!fromUser) {
return
}
val intValue = value.toInt()
val filter = FilterCoordinator.require(this)
when (slider.id) {
R.id.slider_year -> filter.setYear(
if (intValue <= slider.valueFrom.toIntUp()) {
YEAR_UNKNOWN
} else {
intValue
},
)
}
}
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
val filter = FilterCoordinator.require(this)
when (parent.id) {
R.id.spinner_order -> filter.setSortOrder(filter.sortOrder.value.availableItems[position])
R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position])
R.id.spinner_original_locale -> filter.setOriginalLocale(filter.originalLocale.value.availableItems[position])
}
}
private fun onRangeSliderValueChange(slider: RangeSlider, value: Float, fromUser: Boolean) {
if (!fromUser) {
return
}
val filter = FilterCoordinator.require(this)
when (slider.id) {
R.id.slider_yearsRange -> filter.setYearRange(
valueFrom = slider.values.firstOrNull()?.let {
if (it <= slider.valueFrom) YEAR_UNKNOWN else it.toInt()
} ?: YEAR_UNKNOWN,
valueTo = slider.values.lastOrNull()?.let {
if (it >= slider.valueTo) YEAR_UNKNOWN else it.toInt()
} ?: YEAR_UNKNOWN,
)
}
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
override fun onChipClick(chip: Chip, data: Any?) {
val filter = FilterCoordinator.require(this)
when (data) {
is MangaState -> filter.toggleState(data, !chip.isChecked)
is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) {
filter.toggleTagExclude(data, !chip.isChecked)
} else {
filter.toggleTag(data, !chip.isChecked)
}
private fun onSliderValueChange(slider: Slider, value: Float, fromUser: Boolean) {
if (!fromUser) {
return
}
val intValue = value.toInt()
val filter = FilterCoordinator.require(this)
when (slider.id) {
R.id.slider_year -> filter.setYear(
if (intValue <= slider.valueFrom.toIntUp()) {
YEAR_UNKNOWN
} else {
intValue
},
)
}
}
is ContentType -> filter.toggleContentType(data, !chip.isChecked)
is ContentRating -> filter.toggleContentRating(data, !chip.isChecked)
is Demographic -> filter.toggleDemographic(data, !chip.isChecked)
null -> router.showTagsCatalogSheet(excludeMode = chip.parentView?.id == R.id.chips_genresExclude)
}
}
private fun onRangeSliderValueChange(slider: RangeSlider, value: Float, fromUser: Boolean) {
if (!fromUser) {
return
}
val filter = FilterCoordinator.require(this)
when (slider.id) {
R.id.slider_yearsRange -> filter.setYearRange(
valueFrom = slider.values.firstOrNull()?.let {
if (it <= slider.valueFrom) YEAR_UNKNOWN else it.toInt()
} ?: YEAR_UNKNOWN,
valueTo = slider.values.lastOrNull()?.let {
if (it >= slider.valueTo) YEAR_UNKNOWN else it.toInt()
} ?: YEAR_UNKNOWN,
)
}
}
private fun onSortOrderChanged(value: FilterProperty<SortOrder>) {
val b = viewBinding ?: return
b.layoutOrder.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val selected = value.selectedItems.single()
b.spinnerOrder.adapter = ArrayAdapter(
b.spinnerOrder.context,
android.R.layout.simple_spinner_dropdown_item,
android.R.id.text1,
value.availableItems.map { b.spinnerOrder.context.getString(it.titleRes) },
)
val selectedIndex = value.availableItems.indexOf(selected)
if (selectedIndex >= 0) {
b.spinnerOrder.setSelection(selectedIndex, false)
}
}
override fun onChipClick(chip: Chip, data: Any?) {
val filter = FilterCoordinator.require(this)
when (data) {
is MangaState -> filter.toggleState(data, !chip.isChecked)
is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) {
filter.toggleTagExclude(data, !chip.isChecked)
} else {
filter.toggleTag(data, !chip.isChecked)
}
private fun onLocaleChanged(value: FilterProperty<Locale?>) {
val b = viewBinding ?: return
b.layoutLocale.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val selected = value.selectedItems.singleOrNull()
b.spinnerLocale.adapter = ArrayAdapter(
b.spinnerLocale.context,
android.R.layout.simple_spinner_dropdown_item,
android.R.id.text1,
value.availableItems.map { it.getDisplayName(b.spinnerLocale.context) },
)
val selectedIndex = value.availableItems.indexOf(selected)
if (selectedIndex >= 0) {
b.spinnerLocale.setSelection(selectedIndex, false)
}
}
is ContentType -> filter.toggleContentType(data, !chip.isChecked)
is ContentRating -> filter.toggleContentRating(data, !chip.isChecked)
is Demographic -> filter.toggleDemographic(data, !chip.isChecked)
is PersistableFilter -> filter.setAdjusted(data.filter)
is String -> if (chip.isChecked) {
filter.setAuthor(null)
} else {
filter.setAuthor(data)
}
null -> router.showTagsCatalogSheet(excludeMode = chip.parentView?.id == R.id.chips_genresExclude)
}
}
private fun onOriginalLocaleChanged(value: FilterProperty<Locale?>) {
val b = viewBinding ?: return
b.layoutOriginalLocale.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val selected = value.selectedItems.singleOrNull()
b.spinnerOriginalLocale.adapter = ArrayAdapter(
b.spinnerOriginalLocale.context,
android.R.layout.simple_spinner_dropdown_item,
android.R.id.text1,
value.availableItems.map { it.getDisplayName(b.spinnerOriginalLocale.context) },
)
val selectedIndex = value.availableItems.indexOf(selected)
if (selectedIndex >= 0) {
b.spinnerOriginalLocale.setSelection(selectedIndex, false)
}
}
override fun onChipLongClick(chip: Chip, data: Any?): Boolean {
return when (data) {
is PersistableFilter -> {
showSavedFilterMenu(chip, data)
true
}
private fun onTagsChanged(value: FilterProperty<MangaTag>) {
val b = viewBinding ?: return
b.layoutGenres.isGone = value.isEmptyAndSuccess()
b.layoutGenres.setError(value.error?.getDisplayMessage(resources))
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { tag ->
ChipsView.ChipModel(
title = tag.title,
isChecked = tag in value.selectedItems,
data = tag,
)
}
b.chipsGenres.setChips(chips)
}
else -> false
}
}
private fun onTagsExcludedChanged(value: FilterProperty<MangaTag>) {
val b = viewBinding ?: return
b.layoutGenresExclude.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { tag ->
ChipsView.ChipModel(
title = tag.title,
isChecked = tag in value.selectedItems,
data = tag,
)
}
b.chipsGenresExclude.setChips(chips)
}
override fun onChipCloseClick(chip: Chip, data: Any?) {
when (data) {
is PersistableFilter -> {
showSavedFilterMenu(chip, data)
}
}
}
private fun onStateChanged(value: FilterProperty<MangaState>) {
val b = viewBinding ?: return
b.layoutState.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { state ->
ChipsView.ChipModel(
title = getString(state.titleResId),
isChecked = state in value.selectedItems,
data = state,
)
}
b.chipsState.setChips(chips)
}
private fun onSortOrderChanged(value: FilterProperty<SortOrder>) {
val b = viewBinding ?: return
b.layoutOrder.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val selected = value.selectedItems.single()
b.spinnerOrder.adapter = ArrayAdapter(
b.spinnerOrder.context,
android.R.layout.simple_spinner_dropdown_item,
android.R.id.text1,
value.availableItems.map { b.spinnerOrder.context.getString(it.titleRes) },
)
val selectedIndex = value.availableItems.indexOf(selected)
if (selectedIndex >= 0) {
b.spinnerOrder.setSelection(selectedIndex, false)
}
}
private fun onContentTypesChanged(value: FilterProperty<ContentType>) {
val b = viewBinding ?: return
b.layoutTypes.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { type ->
ChipsView.ChipModel(
title = getString(type.titleResId),
isChecked = type in value.selectedItems,
data = type,
)
}
b.chipsTypes.setChips(chips)
}
private fun onLocaleChanged(value: FilterProperty<Locale?>) {
val b = viewBinding ?: return
b.layoutLocale.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val selected = value.selectedItems.singleOrNull()
b.spinnerLocale.adapter = ArrayAdapter(
b.spinnerLocale.context,
android.R.layout.simple_spinner_dropdown_item,
android.R.id.text1,
value.availableItems.map { it.getDisplayName(b.spinnerLocale.context) },
)
val selectedIndex = value.availableItems.indexOf(selected)
if (selectedIndex >= 0) {
b.spinnerLocale.setSelection(selectedIndex, false)
}
}
private fun onContentRatingChanged(value: FilterProperty<ContentRating>) {
val b = viewBinding ?: return
b.layoutContentRating.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { contentRating ->
ChipsView.ChipModel(
title = getString(contentRating.titleResId),
isChecked = contentRating in value.selectedItems,
data = contentRating,
)
}
b.chipsContentRating.setChips(chips)
}
private fun onOriginalLocaleChanged(value: FilterProperty<Locale?>) {
val b = viewBinding ?: return
b.layoutOriginalLocale.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val selected = value.selectedItems.singleOrNull()
b.spinnerOriginalLocale.adapter = ArrayAdapter(
b.spinnerOriginalLocale.context,
android.R.layout.simple_spinner_dropdown_item,
android.R.id.text1,
value.availableItems.map { it.getDisplayName(b.spinnerOriginalLocale.context) },
)
val selectedIndex = value.availableItems.indexOf(selected)
if (selectedIndex >= 0) {
b.spinnerOriginalLocale.setSelection(selectedIndex, false)
}
}
private fun onDemographicsChanged(value: FilterProperty<Demographic>) {
val b = viewBinding ?: return
b.layoutDemographics.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { demographic ->
ChipsView.ChipModel(
title = getString(demographic.titleResId),
isChecked = demographic in value.selectedItems,
data = demographic,
)
}
b.chipsDemographics.setChips(chips)
}
private fun onTagsChanged(value: FilterProperty<MangaTag>) {
val b = viewBinding ?: return
b.layoutGenres.isGone = value.isEmptyAndSuccess()
b.layoutGenres.setError(value.error?.getDisplayMessage(resources))
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { tag ->
ChipsView.ChipModel(
title = tag.title,
isChecked = tag in value.selectedItems,
data = tag,
)
}
b.chipsGenres.setChips(chips)
}
private fun onYearChanged(value: FilterProperty<Int>) {
val b = viewBinding ?: return
b.layoutYear.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val currentValue = value.selectedItems.singleOrNull() ?: YEAR_UNKNOWN
b.layoutYear.setValueText(
if (currentValue == YEAR_UNKNOWN) {
getString(R.string.any)
} else {
currentValue.toString()
},
)
b.sliderYear.valueFrom = value.availableItems.first().toFloat()
b.sliderYear.valueTo = value.availableItems.last().toFloat()
b.sliderYear.setValueRounded(currentValue.toFloat())
}
private fun onTagsExcludedChanged(value: FilterProperty<MangaTag>) {
val b = viewBinding ?: return
b.layoutGenresExclude.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { tag ->
ChipsView.ChipModel(
title = tag.title,
isChecked = tag in value.selectedItems,
data = tag,
)
}
b.chipsGenresExclude.setChips(chips)
}
private fun onYearRangeChanged(value: FilterProperty<Int>) {
val b = viewBinding ?: return
b.layoutYearsRange.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
b.sliderYearsRange.valueFrom = value.availableItems.first().toFloat()
b.sliderYearsRange.valueTo = value.availableItems.last().toFloat()
val currentValueFrom = value.selectedItems.firstOrNull()?.toFloat() ?: b.sliderYearsRange.valueFrom
val currentValueTo = value.selectedItems.lastOrNull()?.toFloat() ?: b.sliderYearsRange.valueTo
b.layoutYearsRange.setValueText(
getString(
R.string.memory_usage_pattern,
currentValueFrom.toInt().toString(),
currentValueTo.toInt().toString(),
),
)
b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo)
}
private fun onAuthorsChanged(value: FilterProperty<String>) {
val b = viewBinding ?: return
b.layoutAuthor.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { author ->
ChipsView.ChipModel(
title = author,
isChecked = author in value.selectedItems,
data = author,
)
}
b.chipsAuthor.setChips(chips)
}
private fun onStateChanged(value: FilterProperty<MangaState>) {
val b = viewBinding ?: return
b.layoutState.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { state ->
ChipsView.ChipModel(
title = getString(state.titleResId),
isChecked = state in value.selectedItems,
data = state,
)
}
b.chipsState.setChips(chips)
}
private fun onContentTypesChanged(value: FilterProperty<ContentType>) {
val b = viewBinding ?: return
b.layoutTypes.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { type ->
ChipsView.ChipModel(
title = getString(type.titleResId),
isChecked = type in value.selectedItems,
data = type,
)
}
b.chipsTypes.setChips(chips)
}
private fun onContentRatingChanged(value: FilterProperty<ContentRating>) {
val b = viewBinding ?: return
b.layoutContentRating.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { contentRating ->
ChipsView.ChipModel(
title = getString(contentRating.titleResId),
isChecked = contentRating in value.selectedItems,
data = contentRating,
)
}
b.chipsContentRating.setChips(chips)
}
private fun onDemographicsChanged(value: FilterProperty<Demographic>) {
val b = viewBinding ?: return
b.layoutDemographics.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { demographic ->
ChipsView.ChipModel(
title = getString(demographic.titleResId),
isChecked = demographic in value.selectedItems,
data = demographic,
)
}
b.chipsDemographics.setChips(chips)
}
private fun onYearChanged(value: FilterProperty<Int>) {
val b = viewBinding ?: return
b.layoutYear.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val currentValue = value.selectedItems.singleOrNull() ?: YEAR_UNKNOWN
b.layoutYear.setValueText(
if (currentValue == YEAR_UNKNOWN) {
getString(R.string.any)
} else {
currentValue.toString()
},
)
b.sliderYear.valueFrom = value.availableItems.first().toFloat()
b.sliderYear.valueTo = value.availableItems.last().toFloat()
b.sliderYear.setValueRounded(currentValue.toFloat())
}
private fun onYearRangeChanged(value: FilterProperty<Int>) {
val b = viewBinding ?: return
b.layoutYearsRange.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
b.sliderYearsRange.valueFrom = value.availableItems.first().toFloat()
b.sliderYearsRange.valueTo = value.availableItems.last().toFloat()
val currentValueFrom = value.selectedItems.firstOrNull()?.toFloat() ?: b.sliderYearsRange.valueFrom
val currentValueTo = value.selectedItems.lastOrNull()?.toFloat() ?: b.sliderYearsRange.valueTo
b.layoutYearsRange.setValueText(
getString(
R.string.memory_usage_pattern,
currentValueFrom.toInt().toString(),
currentValueTo.toInt().toString(),
),
)
b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo)
}
private fun onSavedPresetsChanged(value: FilterProperty<PersistableFilter>) {
val b = viewBinding ?: return
b.layoutSavedFilters.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { f ->
ChipsView.ChipModel(
title = f.name,
isChecked = f in value.selectedItems,
data = f,
isDropdown = true,
)
}
b.chipsSavedFilters.setChips(chips)
}
private fun showSavedFilterMenu(anchor: View, preset: PersistableFilter) {
val menu = PopupMenu(context ?: return, anchor)
val filter = FilterCoordinator.require(this)
menu.inflate(R.menu.popup_saved_filter)
menu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_delete -> filter.deleteSavedFilter(preset.id)
R.id.action_rename -> onRenameFilterClick(preset)
}
true
}
menu.show()
}
private fun onSaveFilterClick(name: String) {
val filter = FilterCoordinator.require(this)
val existingNames = filter.savedFilters.value.availableItems
.mapTo(TreeSet(AlphanumComparator()), PersistableFilter::name)
buildAlertDialog(context ?: return) {
val input = setEditText(
entries = existingNames.toList(),
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES,
singleLine = true,
)
input.setHint(R.string.enter_name)
input.setText(name)
input.filters += InputFilter.LengthFilter(MAX_TITLE_LENGTH)
setTitle(R.string.save_filter)
setPositiveButton(R.string.save) { _, _ ->
val text = input.text?.toString()?.trim()
if (text.isNullOrEmpty()) {
Toast.makeText(context, R.string.invalid_value_message, Toast.LENGTH_SHORT).show()
onSaveFilterClick("")
} else if (text in existingNames) {
askForFilterOverwrite(filter, text)
} else {
filter.saveCurrentFilter(text)
}
}
setNegativeButton(android.R.string.cancel, null)
}.show()
}
private fun onRenameFilterClick(preset: PersistableFilter) {
val filter = FilterCoordinator.require(this)
val existingNames = filter.savedFilters.value.availableItems.mapToSet { it.name }
buildAlertDialog(context ?: return) {
val input = setEditText(
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES,
singleLine = true,
)
input.filters += InputFilter.LengthFilter(MAX_TITLE_LENGTH)
input.setHint(R.string.enter_name)
input.setText(preset.name)
setTitle(R.string.rename)
setPositiveButton(R.string.save) { _, _ ->
val text = input.text?.toString()?.trim()
if (text.isNullOrEmpty() || text in existingNames) {
Toast.makeText(context, R.string.invalid_value_message, Toast.LENGTH_SHORT).show()
} else {
filter.renameSavedFilter(preset.id, text)
}
}
setNegativeButton(android.R.string.cancel, null)
}.show()
}
private fun askForFilterOverwrite(filter: FilterCoordinator, name: String) {
buildAlertDialog(context ?: return) {
setTitle(R.string.save_filter)
setMessage(getString(R.string.filter_overwrite_confirm, name))
setPositiveButton(R.string.overwrite) { _, _ ->
filter.saveCurrentFilter(name)
}
setNegativeButton(android.R.string.cancel) { _, _ ->
onSaveFilterClick(name)
}
}.show()
}
}

View File

@@ -43,6 +43,9 @@ import org.koitharu.kotatsu.parsers.model.Manga
import java.time.Instant
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
@@ -54,7 +57,8 @@ class HistoryListViewModel @Inject constructor(
private val markAsReadUseCase: MarkAsReadUseCase,
private val quickFilter: HistoryListQuickFilter,
mangaDataRepository: MangaDataRepository,
) : MangaListViewModel(settings, mangaDataRepository), QuickFilterListener by quickFilter {
@LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>,
) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges), QuickFilterListener by quickFilter {
private val sortOrder: StateFlow<ListSortOrder> = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.IO,

View File

@@ -2,14 +2,12 @@ package org.koitharu.kotatsu.image.ui
import android.content.Context
import android.graphics.drawable.LayerDrawable
import android.os.Build
import android.util.AttributeSet
import android.view.Gravity
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.ViewTreeObserver.OnPreDrawListener
import androidx.annotation.AttrRes
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes
import androidx.core.graphics.ColorUtils
@@ -83,9 +81,7 @@ class CoverImageView @JvmOverloads constructor(
if (fallbackDrawable == null) {
fallbackDrawable = context.getThemeColor(materialR.attr.colorSurfaceContainer).toDrawable()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
addImageRequestListener(ErrorForegroundListener())
}
addImageRequestListener(ErrorForegroundListener())
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
@@ -169,7 +165,6 @@ class CoverImageView @JvmOverloads constructor(
}
}
@RequiresApi(Build.VERSION_CODES.M)
private inner class ErrorForegroundListener : ImageRequest.Listener {
override fun onSuccess(request: ImageRequest, result: SuccessResult) {
@@ -208,6 +203,7 @@ class CoverImageView @JvmOverloads constructor(
is HttpStatusException -> statusCode.toString()
is ContentUnavailableException,
is FileNotFoundException -> "404"
is TooManyRequestExceptions -> "429"
is ParseException -> "</>"
is UnsupportedSourceException -> "X"
@@ -269,7 +265,7 @@ class CoverImageView @JvmOverloads constructor(
width = Dimension(height.px * view.aspectRationWidth / view.aspectRationHeight)
}
}
return Size(checkNotNull(width), checkNotNull(height))
return Size(width, height)
}
private fun getWidth() = getDimension(

View File

@@ -3,10 +3,12 @@ package org.koitharu.kotatsu.list.ui
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
@@ -22,10 +24,13 @@ import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga
abstract class MangaListViewModel(
private val settings: AppSettings,
private val mangaDataRepository: MangaDataRepository,
@param:LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
) : BaseViewModel() {
abstract val content: StateFlow<List<ListModel>>
@@ -63,7 +68,11 @@ abstract class MangaListViewModel(
protected fun observeListModeWithTriggers(): Flow<ListMode> = combine(
listMode,
mangaDataRepository.observeOverridesTrigger(emitInitialState = true),
merge(
mangaDataRepository.observeOverridesTrigger(emitInitialState = true),
mangaDataRepository.observeFavoritesTrigger(emitInitialState = true),
localStorageChanges.onStart { emit(null) },
),
settings.observeChanges().filter { key ->
key == AppSettings.KEY_PROGRESS_INDICATORS
|| key == AppSettings.KEY_TRACKER_ENABLED

View File

@@ -13,11 +13,11 @@ import androidx.annotation.WorkerThread
import androidx.core.content.ContextCompat
import androidx.core.net.toFile
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.Cache
import org.koitharu.kotatsu.core.LocalizedAppContext
import org.koitharu.kotatsu.core.exceptions.NonFileUriException
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.computeSize
@@ -39,8 +39,8 @@ private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
@Reusable
class LocalStorageManager @Inject constructor(
@ApplicationContext private val context: Context,
private val settings: AppSettings,
@LocalizedAppContext private val context: Context,
private val settings: AppSettings,
) {
val contentResolver: ContentResolver

View File

@@ -27,7 +27,6 @@ import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
import org.koitharu.kotatsu.parsers.util.json.toStringSet
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.io.File

View File

@@ -61,7 +61,9 @@ class LocalMangaParser(private val uri: Uri) {
val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX)
val mangaInfo = index?.getMangaInfo()
if (mangaInfo != null) {
val coverEntry: Path? = index.getCoverEntry()?.let { rootPath / it }
val coverEntry: Path? = index.getCoverEntry()?.let { rootPath / it }?.takeIf {
fileSystem.exists(it)
}
mangaInfo.copy(
source = LocalMangaSource,
url = rootFile.toUri().toString(),

View File

@@ -45,7 +45,7 @@ class LocalListViewModel @Inject constructor(
mangaListMapper: MangaListMapper,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
exploreRepository: ExploreRepository,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
@param:LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
private val localStorageManager: LocalStorageManager,
sourcesRepository: MangaSourcesRepository,
mangaDataRepository: MangaDataRepository,
@@ -58,6 +58,7 @@ class LocalListViewModel @Inject constructor(
exploreRepository = exploreRepository,
sourcesRepository = sourcesRepository,
mangaDataRepository = mangaDataRepository,
localStorageChanges = localStorageChanges,
), SharedPreferences.OnSharedPreferenceChangeListener, QuickFilterListener {
val onMangaRemoved = MutableEventFlow<Unit>()

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.main.ui
import android.Manifest
import android.app.BackgroundServiceStartNotAllowedException
import android.app.ServiceStartNotAllowedException
import android.content.Intent
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Build
@@ -58,6 +60,7 @@ import org.koitharu.kotatsu.core.util.ext.consume
import org.koitharu.kotatsu.core.util.ext.end
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.start
import org.koitharu.kotatsu.databinding.ActivityMainBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService
@@ -131,7 +134,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
onBackPressedDispatcher.addCallback(exitCallback)
onBackPressedDispatcher.addCallback(navigationDelegate)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || !resources.getBoolean(R.bool.is_predictive_back_enabled)) {
val legacySearchCallback = SearchViewLegacyBackCallback(viewBinding.searchView)
viewBinding.searchView.addTransitionListener(legacySearchCallback)
onBackPressedDispatcher.addCallback(legacySearchCallback)
@@ -288,7 +291,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
adjustFabVisibility(isResumeEnabled = isEnabled)
}
private fun onFirstStart() {
private fun onFirstStart() = try {
lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher
withContext(Dispatchers.Default) {
LocalStorageCleanupWorker.enqueue(applicationContext)
@@ -303,6 +306,8 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
}
}
}
} catch (e: IllegalStateException) {
e.printStackTraceDebug()
}
private fun adjustAppbar(topFragment: Fragment) {

View File

@@ -51,6 +51,7 @@ class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipC
binding.chipsType.onChipClickListener = this
binding.chipBackup.setOnClickListener(this)
binding.chipSync.setOnClickListener(this)
binding.chipDirectories.setOnClickListener(this)
viewModel.locales.observe(viewLifecycleOwner, ::onLocalesChanged)
viewModel.types.observe(viewLifecycleOwner, ::onTypesChanged)
@@ -86,6 +87,10 @@ class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipC
val accountType = getString(R.string.account_type_sync)
am.addAccount(accountType, accountType, null, null, requireActivity(), null, null)
}
R.id.chip_directories -> {
router.openDirectoriesSettings()
}
}
}

View File

@@ -20,6 +20,9 @@ import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import javax.inject.Inject
import kotlinx.coroutines.flow.SharedFlow
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga
@HiltViewModel
class MangaPickerViewModel @Inject constructor(
@@ -28,7 +31,8 @@ class MangaPickerViewModel @Inject constructor(
private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository,
private val mangaListMapper: MangaListMapper,
) : MangaListViewModel(settings, mangaDataRepository) {
@LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>,
) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges) {
override val content: StateFlow<List<ListModel>>
get() = flow {

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.reader.domain
import android.util.LongSparseArray
import androidx.annotation.CheckResult
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -32,12 +33,12 @@ class ChaptersLoader @Inject constructor(
}
}
suspend fun loadPrevNextChapter(manga: MangaDetails, currentId: Long, isNext: Boolean) {
suspend fun loadPrevNextChapter(manga: MangaDetails, currentId: Long, isNext: Boolean): Boolean {
val chapters = manga.allChapters
val predicate: (MangaChapter) -> Boolean = { it.id == currentId }
val index = if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate)
if (index == -1) return
val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return
if (index == -1) return false
val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return false
val newPages = loadChapter(newChapter.id)
mutex.withLock {
if (chapterPages.chaptersSize > 1) {
@@ -56,13 +57,16 @@ class ChaptersLoader @Inject constructor(
chapterPages.addFirst(newChapter.id, newPages)
}
}
return true
}
suspend fun loadSingleChapter(chapterId: Long) {
@CheckResult
suspend fun loadSingleChapter(chapterId: Long): Boolean {
val pages = loadChapter(chapterId)
mutex.withLock {
return mutex.withLock {
chapterPages.clear()
chapterPages.addLast(chapterId, pages)
pages.isNotEmpty()
}
}

View File

@@ -8,11 +8,9 @@ import android.graphics.Rect
import androidx.annotation.ColorInt
import androidx.core.graphics.alpha
import androidx.core.graphics.blue
import androidx.core.graphics.get
import androidx.core.graphics.green
import androidx.core.graphics.red
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.decoder.ImageRegionDecoder
import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -23,7 +21,6 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.util.SynchronizedSieveCache
import org.koitharu.kotatsu.core.util.ext.use
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
@@ -46,19 +43,19 @@ class EdgeDetector(private val context: Context) {
}
val scaleFactor = calculateScaleFactor(size)
val sampleSize = (1f / scaleFactor).toInt().coerceAtLeast(1)
val fullBitmap = decoder.decodeRegion(
Rect(0, 0, size.x, size.y),
sampleSize
Rect(0, 0, size.x, size.y),
sampleSize,
)
try {
val edges = coroutineScope {
listOf(
async { detectLeftRightEdge(fullBitmap, size, sampleSize, isLeft = true) },
async { detectTopBottomEdge(fullBitmap, size, sampleSize, isTop = true) },
async { detectLeftRightEdge(fullBitmap, size, sampleSize, isLeft = false) },
async { detectTopBottomEdge(fullBitmap, size, sampleSize, isTop = false) },
async { detectLeftRightEdge(fullBitmap, size, sampleSize, isLeft = true) },
async { detectTopBottomEdge(fullBitmap, size, sampleSize, isTop = true) },
async { detectLeftRightEdge(fullBitmap, size, sampleSize, isLeft = false) },
async { detectTopBottomEdge(fullBitmap, size, sampleSize, isTop = false) },
).awaitAll()
}
var hasEdges = false
@@ -91,10 +88,10 @@ class EdgeDetector(private val context: Context) {
val rectCount = size.x / BLOCK_SIZE
val maxRect = rectCount / 3
val blockPixels = IntArray(BLOCK_SIZE * BLOCK_SIZE)
val bitmapWidth = bitmap.width
val bitmapHeight = bitmap.height
for (i in 0 until rectCount) {
if (i > maxRect) {
return -1
@@ -103,16 +100,16 @@ class EdgeDetector(private val context: Context) {
for (j in 0 until size.y / BLOCK_SIZE) {
val regionX = if (isLeft) i * BLOCK_SIZE else size.x - (i + 1) * BLOCK_SIZE
val regionY = j * BLOCK_SIZE
// Convert to bitmap coordinates
val bitmapX = regionX / sampleSize
val bitmapY = regionY / sampleSize
val blockWidth = min(BLOCK_SIZE / sampleSize, bitmapWidth - bitmapX)
val blockHeight = min(BLOCK_SIZE / sampleSize, bitmapHeight - bitmapY)
if (blockWidth > 0 && blockHeight > 0) {
bitmap.getPixels(blockPixels, 0, blockWidth, bitmapX, bitmapY, blockWidth, blockHeight)
for (ii in 0 until minOf(blockWidth, dd / sampleSize)) {
for (jj in 0 until blockHeight) {
val bi = if (isLeft) ii else blockWidth - ii - 1
@@ -141,10 +138,10 @@ class EdgeDetector(private val context: Context) {
val rectCount = size.y / BLOCK_SIZE
val maxRect = rectCount / 3
val blockPixels = IntArray(BLOCK_SIZE * BLOCK_SIZE)
val bitmapWidth = bitmap.width
val bitmapHeight = bitmap.height
for (j in 0 until rectCount) {
if (j > maxRect) {
return -1
@@ -153,16 +150,16 @@ class EdgeDetector(private val context: Context) {
for (i in 0 until size.x / BLOCK_SIZE) {
val regionX = i * BLOCK_SIZE
val regionY = if (isTop) j * BLOCK_SIZE else size.y - (j + 1) * BLOCK_SIZE
// Convert to bitmap coordinates
val bitmapX = regionX / sampleSize
val bitmapY = regionY / sampleSize
val blockWidth = min(BLOCK_SIZE / sampleSize, bitmapWidth - bitmapX)
val blockHeight = min(BLOCK_SIZE / sampleSize, bitmapHeight - bitmapY)
if (blockWidth > 0 && blockHeight > 0) {
bitmap.getPixels(blockPixels, 0, blockWidth, bitmapX, bitmapY, blockWidth, blockHeight)
for (jj in 0 until minOf(blockHeight, dd / sampleSize)) {
for (ii in 0 until blockWidth) {
val bj = if (isTop) jj else blockHeight - jj - 1
@@ -218,4 +215,4 @@ class EdgeDetector(private val context: Context) {
private fun region(x: Int, y: Int) = Rect(x, y, x + BLOCK_SIZE, y + BLOCK_SIZE)
}
}
}

View File

@@ -3,7 +3,7 @@ package org.koitharu.kotatsu.reader.ui
import android.app.assist.AssistContent
import android.content.DialogInterface
import android.content.Intent
import android.os.Build
import android.content.res.Configuration
import android.os.Bundle
import android.view.Gravity
import android.view.KeyEvent
@@ -25,6 +25,8 @@ import androidx.transition.Fade
import androidx.transition.Slide
import androidx.transition.TransitionManager
import androidx.transition.TransitionSet
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
@@ -32,7 +34,9 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
@@ -71,474 +75,519 @@ import androidx.appcompat.R as appcompatR
@AndroidEntryPoint
class ReaderActivity :
BaseFullscreenActivity<ActivityReaderBinding>(),
TapGridDispatcher.OnGridTouchListener,
ReaderConfigSheet.Callback,
ReaderControlDelegate.OnInteractionListener,
ReaderNavigationCallback,
IdlingDetector.Callback,
ZoomControl.ZoomControlListener,
View.OnClickListener,
ScrollTimerControlView.OnVisibilityChangeListener {
BaseFullscreenActivity<ActivityReaderBinding>(),
TapGridDispatcher.OnGridTouchListener,
ReaderConfigSheet.Callback,
ReaderControlDelegate.OnInteractionListener,
ReaderNavigationCallback,
IdlingDetector.Callback,
ZoomControl.ZoomControlListener,
View.OnClickListener,
ScrollTimerControlView.OnVisibilityChangeListener {
@Inject
lateinit var settings: AppSettings
@Inject
lateinit var settings: AppSettings
@Inject
lateinit var tapGridSettings: TapGridSettings
@Inject
lateinit var tapGridSettings: TapGridSettings
@Inject
lateinit var pageSaveHelperFactory: PageSaveHelper.Factory
@Inject
lateinit var pageSaveHelperFactory: PageSaveHelper.Factory
@Inject
lateinit var scrollTimerFactory: ScrollTimer.Factory
@Inject
lateinit var scrollTimerFactory: ScrollTimer.Factory
@Inject
lateinit var screenOrientationHelper: ScreenOrientationHelper
@Inject
lateinit var screenOrientationHelper: ScreenOrientationHelper
private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this)
private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this)
private val viewModel: ReaderViewModel by viewModels()
private val viewModel: ReaderViewModel by viewModels()
override val readerMode: ReaderMode?
get() = readerManager.currentMode
override val readerMode: ReaderMode?
get() = readerManager.currentMode
private lateinit var scrollTimer: ScrollTimer
private lateinit var pageSaveHelper: PageSaveHelper
private lateinit var touchHelper: TapGridDispatcher
private lateinit var controlDelegate: ReaderControlDelegate
private var gestureInsets: Insets = Insets.NONE
private lateinit var readerManager: ReaderManager
private val hideUiRunnable = Runnable { setUiIsVisible(false) }
private lateinit var scrollTimer: ScrollTimer
private lateinit var pageSaveHelper: PageSaveHelper
private lateinit var touchHelper: TapGridDispatcher
private lateinit var controlDelegate: ReaderControlDelegate
private var gestureInsets: Insets = Insets.NONE
private lateinit var readerManager: ReaderManager
private val hideUiRunnable = Runnable { setUiIsVisible(false) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityReaderBinding.inflate(layoutInflater))
readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings)
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
touchHelper = TapGridDispatcher(viewBinding.root, this)
scrollTimer = scrollTimerFactory.create(resources, this, this)
pageSaveHelper = pageSaveHelperFactory.create(this)
controlDelegate = ReaderControlDelegate(resources, settings, tapGridSettings, this)
viewBinding.zoomControl.listener = this
viewBinding.actionsView.listener = this
viewBinding.buttonTimer?.setOnClickListener(this)
idlingDetector.bindToLifecycle(this)
screenOrientationHelper.applySettings()
viewModel.isBookmarkAdded.observe(this) { viewBinding.actionsView.isBookmarkAdded = it }
scrollTimer.isActive.observe(this) {
updateScrollTimerButton()
viewBinding.actionsView.setTimerActive(it)
}
viewBinding.timerControl.onVisibilityChangeListener = this
viewBinding.timerControl.attach(scrollTimer, this)
if (resources.getBoolean(R.bool.is_tablet)) {
viewBinding.timerControl.updateLayoutParams<CoordinatorLayout.LayoutParams> {
topMargin = marginEnd + getThemeDimensionPixelOffset(appcompatR.attr.actionBarSize)
}
}
// Tracks whether the foldable device is in an unfolded state (half-opened or flat)
private var isFoldUnfolded: Boolean = false
viewModel.onLoadingError.observeEvent(
this,
DialogErrorObserver(
host = viewBinding.container,
fragment = null,
resolver = exceptionResolver,
onResolved = { isResolved ->
if (isResolved) {
viewModel.reload()
} else if (viewModel.content.value.pages.isEmpty()) {
dispatchNavigateUp()
}
},
),
)
viewModel.onError.observeEvent(
this,
SnackbarErrorObserver(
host = viewBinding.container,
fragment = null,
resolver = exceptionResolver,
onResolved = null,
),
)
viewModel.readerMode.observe(this, Lifecycle.State.STARTED, this::onInitReader)
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(viewBinding.container))
viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged)
combine(
viewModel.isLoading,
viewModel.content.map { it.pages.isNotEmpty() }.distinctUntilChanged(),
::Pair,
).flowOn(Dispatchers.Default)
.observe(this, this::onLoadingStateChanged)
viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn)
viewModel.isInfoBarTransparent.observe(this) { viewBinding.infoBar.drawBackground = !it }
viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged)
viewModel.isBookmarkAdded.observe(this, MenuInvalidator(this))
viewModel.onAskNsfwIncognito.observeEvent(this) { askForIncognitoMode() }
viewModel.onShowToast.observeEvent(this) { msgId ->
Snackbar.make(viewBinding.container, msgId, Snackbar.LENGTH_SHORT)
.setAnchorView(viewBinding.toolbarDocked)
.show()
}
viewModel.readerSettingsProducer.observe(this) {
viewBinding.infoBar.applyColorScheme(isBlackOnWhite = it.background.isLight(this))
}
viewModel.isZoomControlsEnabled.observe(this) {
viewBinding.zoomControl.isVisible = it
}
addMenuProvider(ReaderMenuProvider(viewModel))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityReaderBinding.inflate(layoutInflater))
readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings)
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
touchHelper = TapGridDispatcher(viewBinding.root, this)
scrollTimer = scrollTimerFactory.create(resources, this, this)
pageSaveHelper = pageSaveHelperFactory.create(this)
controlDelegate = ReaderControlDelegate(resources, settings, tapGridSettings, this)
viewBinding.zoomControl.listener = this
viewBinding.actionsView.listener = this
viewBinding.buttonTimer?.setOnClickListener(this)
idlingDetector.bindToLifecycle(this)
screenOrientationHelper.applySettings()
viewModel.isBookmarkAdded.observe(this) { viewBinding.actionsView.isBookmarkAdded = it }
scrollTimer.isActive.observe(this) {
updateScrollTimerButton()
viewBinding.actionsView.setTimerActive(it)
}
viewBinding.timerControl.onVisibilityChangeListener = this
viewBinding.timerControl.attach(scrollTimer, this)
if (resources.getBoolean(R.bool.is_tablet)) {
viewBinding.timerControl.updateLayoutParams<CoordinatorLayout.LayoutParams> {
topMargin = marginEnd + getThemeDimensionPixelOffset(appcompatR.attr.actionBarSize)
}
}
override fun getParentActivityIntent(): Intent? {
val manga = viewModel.getMangaOrNull() ?: return null
return AppRouter.detailsIntent(this, manga)
}
viewModel.onLoadingError.observeEvent(
this,
DialogErrorObserver(
host = viewBinding.container,
fragment = null,
resolver = exceptionResolver,
onResolved = { isResolved ->
if (isResolved) {
viewModel.reload()
} else if (viewModel.content.value.pages.isEmpty()) {
dispatchNavigateUp()
}
},
),
)
viewModel.onError.observeEvent(
this,
SnackbarErrorObserver(
host = viewBinding.container,
fragment = null,
resolver = exceptionResolver,
onResolved = null,
),
)
viewModel.readerMode.observe(this, Lifecycle.State.STARTED, this::onInitReader)
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(viewBinding.container))
viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged)
combine(
viewModel.isLoading,
viewModel.content.map { it.pages.isNotEmpty() }.distinctUntilChanged(),
::Pair,
).flowOn(Dispatchers.Default)
.observe(this, this::onLoadingStateChanged)
viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn)
viewModel.isInfoBarTransparent.observe(this) { viewBinding.infoBar.drawBackground = !it }
viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged)
viewModel.isBookmarkAdded.observe(this, MenuInvalidator(this))
viewModel.onAskNsfwIncognito.observeEvent(this) { askForIncognitoMode() }
viewModel.onShowToast.observeEvent(this) { msgId ->
Snackbar.make(viewBinding.container, msgId, Snackbar.LENGTH_SHORT)
.setAnchorView(viewBinding.toolbarDocked)
.show()
}
viewModel.readerSettingsProducer.observe(this) {
viewBinding.infoBar.applyColorScheme(isBlackOnWhite = it.background.isLight(this))
}
viewModel.isZoomControlsEnabled.observe(this) {
viewBinding.zoomControl.isVisible = it
}
addMenuProvider(ReaderMenuProvider(viewModel))
override fun onUserInteraction() {
super.onUserInteraction()
if (!viewBinding.timerControl.isVisible) {
scrollTimer.onUserInteraction()
}
idlingDetector.onUserInteraction()
}
observeWindowLayout()
override fun onPause() {
super.onPause()
viewModel.onPause()
}
// Apply initial double-mode considering foldable setting
applyDoubleModeAuto()
}
override fun onStop() {
super.onStop()
viewModel.onStop()
}
override fun getParentActivityIntent(): Intent? {
val manga = viewModel.getMangaOrNull() ?: return null
return AppRouter.detailsIntent(this, manga)
}
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 }
}
}
override fun onUserInteraction() {
super.onUserInteraction()
if (!viewBinding.timerControl.isVisible) {
scrollTimer.onUserInteraction()
}
idlingDetector.onUserInteraction()
}
override fun isNsfwContent(): Flow<Boolean> = viewModel.isMangaNsfw
override fun onPause() {
super.onPause()
viewModel.onPause()
}
override fun onIdle() {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
viewModel.onIdle()
}
override fun onStop() {
super.onStop()
viewModel.onStop()
}
override fun onVisibilityChanged(v: View, visibility: Int) {
updateScrollTimerButton()
}
override fun onProvideAssistContent(outContent: AssistContent) {
super.onProvideAssistContent(outContent)
viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it }
}
override fun onZoomIn() {
readerManager.currentReader?.onZoomIn()
}
override fun isNsfwContent(): Flow<Boolean> = viewModel.isMangaNsfw
override fun onZoomOut() {
readerManager.currentReader?.onZoomOut()
}
override fun onIdle() {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
viewModel.onIdle()
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_timer -> onScrollTimerClick(isLongClick = false)
}
}
override fun onVisibilityChanged(v: View, visibility: Int) {
updateScrollTimerButton()
}
private fun onInitReader(mode: ReaderMode?) {
if (mode == null) {
return
}
if (readerManager.currentMode != mode) {
readerManager.replace(mode)
}
if (viewBinding.appbarTop.isVisible) {
lifecycle.postDelayed(TimeUnit.SECONDS.toMillis(1), hideUiRunnable)
}
viewBinding.actionsView.setSliderReversed(mode == ReaderMode.REVERSED)
viewBinding.timerControl.onReaderModeChanged(mode)
}
override fun onZoomIn() {
readerManager.currentReader?.onZoomIn()
}
private fun onLoadingStateChanged(value: Pair<Boolean, Boolean>) {
val (isLoading, hasPages) = value
val showLoadingLayout = isLoading && !hasPages
if (viewBinding.layoutLoading.isVisible != showLoadingLayout) {
val transition = Fade().addTarget(viewBinding.layoutLoading)
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
viewBinding.layoutLoading.isVisible = showLoadingLayout
}
if (isLoading && hasPages) {
viewBinding.toastView.show(R.string.loading_)
} else {
viewBinding.toastView.hide()
}
invalidateOptionsMenu()
}
override fun onZoomOut() {
readerManager.currentReader?.onZoomOut()
}
override fun onGridTouch(area: TapGridArea): Boolean {
return isReaderResumed() && controlDelegate.onGridTouch(area)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_timer -> onScrollTimerClick(isLongClick = false)
}
}
override fun onGridLongTouch(area: TapGridArea) {
if (isReaderResumed()) {
controlDelegate.onGridLongTouch(area)
}
}
private fun onInitReader(mode: ReaderMode?) {
if (mode == null) {
return
}
if (readerManager.currentMode != mode) {
readerManager.replace(mode)
}
if (viewBinding.appbarTop.isVisible) {
lifecycle.postDelayed(TimeUnit.SECONDS.toMillis(1), hideUiRunnable)
}
viewBinding.actionsView.setSliderReversed(mode == ReaderMode.REVERSED)
viewBinding.timerControl.onReaderModeChanged(mode)
}
override fun onProcessTouch(rawX: Int, rawY: Int): Boolean {
return if (
rawX <= gestureInsets.left ||
rawY <= gestureInsets.top ||
rawX >= viewBinding.root.width - gestureInsets.right ||
rawY >= viewBinding.root.height - gestureInsets.bottom ||
viewBinding.appbarTop.hasGlobalPoint(rawX, rawY) ||
viewBinding.toolbarDocked?.hasGlobalPoint(rawX, rawY) == true
) {
false
} else {
val touchables = window.peekDecorView()?.touchables
touchables?.none { it.hasGlobalPoint(rawX, rawY) } != false
}
}
private fun onLoadingStateChanged(value: Pair<Boolean, Boolean>) {
val (isLoading, hasPages) = value
val showLoadingLayout = isLoading && !hasPages
if (viewBinding.layoutLoading.isVisible != showLoadingLayout) {
val transition = Fade().addTarget(viewBinding.layoutLoading)
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
viewBinding.layoutLoading.isVisible = showLoadingLayout
}
if (isLoading && hasPages) {
viewBinding.toastView.show(R.string.loading_)
} else {
viewBinding.toastView.hide()
}
invalidateOptionsMenu()
}
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
touchHelper.dispatchTouchEvent(ev)
if (!viewBinding.timerControl.hasGlobalPoint(ev.rawX.toInt(), ev.rawY.toInt())) {
scrollTimer.onTouchEvent(ev)
}
return super.dispatchTouchEvent(ev)
}
override fun onGridTouch(area: TapGridArea): Boolean {
return isReaderResumed() && controlDelegate.onGridTouch(area)
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
return controlDelegate.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event)
}
override fun onGridLongTouch(area: TapGridArea) {
if (isReaderResumed()) {
controlDelegate.onGridLongTouch(area)
}
}
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
return controlDelegate.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event)
}
override fun onProcessTouch(rawX: Int, rawY: Int): Boolean {
return if (
rawX <= gestureInsets.left ||
rawY <= gestureInsets.top ||
rawX >= viewBinding.root.width - gestureInsets.right ||
rawY >= viewBinding.root.height - gestureInsets.bottom ||
viewBinding.appbarTop.hasGlobalPoint(rawX, rawY) ||
viewBinding.toolbarDocked?.hasGlobalPoint(rawX, rawY) == true
) {
false
} else {
val touchables = window.peekDecorView()?.touchables
touchables?.none { it.hasGlobalPoint(rawX, rawY) } != false
}
}
override fun onChapterSelected(chapter: MangaChapter): Boolean {
viewModel.switchChapter(chapter.id, 0)
return true
}
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
touchHelper.dispatchTouchEvent(ev)
if (!viewBinding.timerControl.hasGlobalPoint(ev.rawX.toInt(), ev.rawY.toInt())) {
scrollTimer.onTouchEvent(ev)
}
return super.dispatchTouchEvent(ev)
}
override fun onPageSelected(page: ReaderPage): Boolean {
lifecycleScope.launch(Dispatchers.Default) {
val pages = viewModel.content.value.pages
val index = pages.indexOfFirst { it.chapterId == page.chapterId && it.id == page.id }
if (index != -1) {
withContext(Dispatchers.Main) {
readerManager.currentReader?.switchPageTo(index, true)
}
} else {
viewModel.switchChapter(page.chapterId, page.index)
}
}
return true
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
return controlDelegate.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event)
}
override fun onReaderModeChanged(mode: ReaderMode) {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
viewModel.switchMode(mode)
viewBinding.timerControl.onReaderModeChanged(mode)
}
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
return controlDelegate.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event)
}
override fun onDoubleModeChanged(isEnabled: Boolean) {
readerManager.setDoubleReaderMode(isEnabled)
}
override fun onChapterSelected(chapter: MangaChapter): Boolean {
viewModel.switchChapter(chapter.id, 0)
return true
}
private fun setKeepScreenOn(isKeep: Boolean) {
if (isKeep) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
override fun onPageSelected(page: ReaderPage): Boolean {
lifecycleScope.launch(Dispatchers.Default) {
val pages = viewModel.content.value.pages
val index = pages.indexOfFirst { it.chapterId == page.chapterId && it.id == page.id }
if (index != -1) {
withContext(Dispatchers.Main) {
readerManager.currentReader?.switchPageTo(index, true)
}
} else {
viewModel.switchChapter(page.chapterId, page.index)
}
}
return true
}
private fun setUiIsVisible(isUiVisible: Boolean) {
if (viewBinding.appbarTop.isVisible != isUiVisible) {
if (isAnimationsEnabled) {
val transition = TransitionSet()
.setOrdering(TransitionSet.ORDERING_TOGETHER)
.addTransition(Slide(Gravity.TOP).addTarget(viewBinding.appbarTop))
.addTransition(Fade().addTarget(viewBinding.infoBar))
viewBinding.toolbarDocked?.let {
transition.addTransition(Slide(Gravity.BOTTOM).addTarget(it))
}
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
}
val isFullscreen = settings.isReaderFullscreenEnabled
viewBinding.appbarTop.isVisible = isUiVisible
viewBinding.toolbarDocked?.isVisible = isUiVisible
viewBinding.infoBar.isGone = isUiVisible || (!viewModel.isInfoBarEnabled.value)
viewBinding.infoBar.isTimeVisible = isFullscreen
updateScrollTimerButton()
systemUiController.setSystemUiVisible(isUiVisible || !isFullscreen)
}
}
override fun onReaderModeChanged(mode: ReaderMode) {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
viewModel.switchMode(mode)
viewBinding.timerControl.onReaderModeChanged(mode)
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
gestureInsets = insets.getInsets(WindowInsetsCompat.Type.systemGestures())
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
viewBinding.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = systemBars.top
rightMargin = systemBars.right
leftMargin = systemBars.left
}
if (viewBinding.toolbarDocked != null) {
viewBinding.actionsView.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = systemBars.bottom
rightMargin = systemBars.right
leftMargin = systemBars.left
}
}
viewBinding.infoBar.updatePadding(
top = systemBars.top,
)
return WindowInsetsCompat.Builder(insets)
.setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE)
.build()
}
override fun onDoubleModeChanged(isEnabled: Boolean) {
// Combine manual toggle with foldable auto setting
applyDoubleModeAuto(isEnabled)
}
override fun switchPageBy(delta: Int) {
readerManager.currentReader?.switchPageBy(delta)
}
private fun applyDoubleModeAuto(manualEnabled: Boolean? = null) {
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
// Auto double-page on foldable when device is unfolded (half-opened or flat)
val autoFoldable = settings.isReaderDoubleOnFoldable && isFoldUnfolded
val manualLandscape = (manualEnabled ?: settings.isReaderDoubleOnLandscape) && isLandscape
val autoEnabled = autoFoldable || manualLandscape
readerManager.setDoubleReaderMode(autoEnabled)
}
override fun switchChapterBy(delta: Int) {
viewModel.switchChapterBy(delta)
}
private fun setKeepScreenOn(isKeep: Boolean) {
if (isKeep) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
override fun openMenu() {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
val currentMode = readerManager.currentMode ?: return
router.showReaderConfigSheet(currentMode)
}
private fun setUiIsVisible(isUiVisible: Boolean) {
if (viewBinding.appbarTop.isVisible != isUiVisible) {
if (isAnimationsEnabled) {
val transition = TransitionSet()
.setOrdering(TransitionSet.ORDERING_TOGETHER)
.addTransition(Slide(Gravity.TOP).addTarget(viewBinding.appbarTop))
.addTransition(Fade().addTarget(viewBinding.infoBar))
viewBinding.toolbarDocked?.let {
transition.addTransition(Slide(Gravity.BOTTOM).addTarget(it))
}
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
}
val isFullscreen = settings.isReaderFullscreenEnabled
viewBinding.appbarTop.isVisible = isUiVisible
viewBinding.toolbarDocked?.isVisible = isUiVisible
viewBinding.infoBar.isGone = isUiVisible || (!viewModel.isInfoBarEnabled.value)
viewBinding.infoBar.isTimeVisible = isFullscreen
updateScrollTimerButton()
systemUiController.setSystemUiVisible(isUiVisible || !isFullscreen)
viewBinding.root.requestApplyInsets()
}
}
override fun scrollBy(delta: Int, smooth: Boolean): Boolean {
return readerManager.currentReader?.scrollBy(delta, smooth) == true
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
gestureInsets = insets.getInsets(WindowInsetsCompat.Type.systemGestures())
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
viewBinding.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = systemBars.top
rightMargin = systemBars.right
leftMargin = systemBars.left
}
if (viewBinding.toolbarDocked != null) {
viewBinding.actionsView.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = systemBars.bottom
rightMargin = systemBars.right
leftMargin = systemBars.left
}
}
viewBinding.infoBar.updatePadding(
top = systemBars.top,
)
val innerInsets = Insets.of(
systemBars.left,
if (viewBinding.appbarTop.isVisible) viewBinding.appbarTop.height else systemBars.top,
systemBars.right,
viewBinding.toolbarDocked?.takeIf { it.isVisible }?.height ?: systemBars.bottom,
)
return WindowInsetsCompat.Builder(insets)
.setInsets(WindowInsetsCompat.Type.systemBars(), innerInsets)
.build()
}
override fun toggleUiVisibility() {
setUiIsVisible(!viewBinding.appbarTop.isVisible)
}
override fun switchPageBy(delta: Int) {
readerManager.currentReader?.switchPageBy(delta)
}
override fun isReaderResumed(): Boolean {
val reader = readerManager.currentReader ?: return false
return reader.isResumed && supportFragmentManager.fragments.lastOrNull() === reader
}
override fun switchChapterBy(delta: Int) {
viewModel.switchChapterBy(delta)
}
override fun onBookmarkClick() {
viewModel.toggleBookmark()
}
override fun openMenu() {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
val currentMode = readerManager.currentMode ?: return
router.showReaderConfigSheet(currentMode)
}
override fun onSavePageClick() {
viewModel.saveCurrentPage(pageSaveHelper)
}
override fun scrollBy(delta: Int, smooth: Boolean): Boolean {
return readerManager.currentReader?.scrollBy(delta, smooth) == true
}
override fun onScrollTimerClick(isLongClick: Boolean) {
if (isLongClick) {
scrollTimer.setActive(!scrollTimer.isActive.value)
} else {
viewBinding.timerControl.showOrHide()
}
}
override fun toggleUiVisibility() {
setUiIsVisible(!viewBinding.appbarTop.isVisible)
}
override fun toggleScreenOrientation() {
if (screenOrientationHelper.toggleScreenOrientation()) {
Snackbar.make(
viewBinding.container,
if (screenOrientationHelper.isLocked) {
R.string.screen_rotation_locked
} else {
R.string.screen_rotation_unlocked
},
Snackbar.LENGTH_SHORT,
).setAnchorView(viewBinding.toolbarDocked)
.show()
}
}
override fun isReaderResumed(): Boolean {
val reader = readerManager.currentReader ?: return false
return reader.isResumed && supportFragmentManager.fragments.lastOrNull() === reader
}
override fun switchPageTo(index: Int) {
val pages = viewModel.getCurrentChapterPages()
val page = pages?.getOrNull(index) ?: return
val chapterId = viewModel.getCurrentState()?.chapterId ?: return
onPageSelected(ReaderPage(page, index, chapterId))
}
override fun onBookmarkClick() {
viewModel.toggleBookmark()
}
private fun onReaderBarChanged(isBarEnabled: Boolean) {
viewBinding.infoBar.isVisible = isBarEnabled && viewBinding.appbarTop.isGone
}
override fun onSavePageClick() {
viewModel.saveCurrentPage(pageSaveHelper)
}
private fun onUiStateChanged(pair: Pair<ReaderUiState?, ReaderUiState?>) {
val (previous: ReaderUiState?, uiState: ReaderUiState?) = pair
title = uiState?.mangaName ?: getString(R.string.loading_)
viewBinding.infoBar.update(uiState)
if (uiState == null) {
supportActionBar?.subtitle = null
viewBinding.actionsView.setSliderValue(0, 1)
viewBinding.actionsView.isSliderEnabled = false
return
}
val chapterTitle = uiState.getChapterTitle(resources)
supportActionBar?.subtitle = when {
uiState.incognito -> getString(R.string.incognito_mode)
else -> chapterTitle
}
if (chapterTitle != previous?.getChapterTitle(resources) && chapterTitle.isNotEmpty()) {
viewBinding.toastView.showTemporary(chapterTitle, TOAST_DURATION)
}
if (uiState.isSliderAvailable()) {
viewBinding.actionsView.setSliderValue(
value = uiState.currentPage,
max = uiState.totalPages - 1,
)
} else {
viewBinding.actionsView.setSliderValue(0, 1)
}
viewBinding.actionsView.isSliderEnabled = uiState.isSliderAvailable()
viewBinding.actionsView.isNextEnabled = uiState.hasNextChapter()
viewBinding.actionsView.isPrevEnabled = uiState.hasPreviousChapter()
}
override fun onScrollTimerClick(isLongClick: Boolean) {
if (isLongClick) {
scrollTimer.setActive(!scrollTimer.isActive.value)
} else {
viewBinding.timerControl.showOrHide()
}
}
private fun updateScrollTimerButton() {
val button = viewBinding.buttonTimer ?: return
val isButtonVisible = scrollTimer.isActive.value
&& settings.isReaderAutoscrollFabVisible
&& !viewBinding.appbarTop.isVisible
&& !viewBinding.timerControl.isVisible
if (button.isVisible != isButtonVisible) {
val transition = Fade().addTarget(button)
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
button.isVisible = isButtonVisible
}
}
override fun toggleScreenOrientation() {
if (screenOrientationHelper.toggleScreenOrientation()) {
Snackbar.make(
viewBinding.container,
if (screenOrientationHelper.isLocked) {
R.string.screen_rotation_locked
} else {
R.string.screen_rotation_unlocked
},
Snackbar.LENGTH_SHORT,
).setAnchorView(viewBinding.toolbarDocked)
.show()
}
}
private fun askForIncognitoMode() {
buildAlertDialog(this, isCentered = true) {
var dontAskAgain = false
val listener = DialogInterface.OnClickListener { _, which ->
if (which == DialogInterface.BUTTON_NEUTRAL) {
finishAfterTransition()
} else {
viewModel.setIncognitoMode(which == DialogInterface.BUTTON_POSITIVE, dontAskAgain)
}
}
setCheckbox(R.string.dont_ask_again, dontAskAgain) { _, isChecked ->
dontAskAgain = isChecked
}
setIcon(R.drawable.ic_incognito)
setTitle(R.string.incognito_mode)
setMessage(R.string.incognito_mode_hint_nsfw)
setPositiveButton(R.string.incognito, listener)
setNegativeButton(R.string.disable, listener)
setNeutralButton(android.R.string.cancel, listener)
setOnCancelListener { finishAfterTransition() }
setCancelable(true)
}.show()
}
override fun switchPageTo(index: Int) {
val pages = viewModel.getCurrentChapterPages()
val page = pages?.getOrNull(index) ?: return
val chapterId = viewModel.getCurrentState()?.chapterId ?: return
onPageSelected(ReaderPage(page, index, chapterId))
}
companion object {
private fun onReaderBarChanged(isBarEnabled: Boolean) {
viewBinding.infoBar.isVisible = isBarEnabled && viewBinding.appbarTop.isGone
}
private const val TOAST_DURATION = 2000L
}
private fun onUiStateChanged(pair: Pair<ReaderUiState?, ReaderUiState?>) {
val (previous: ReaderUiState?, uiState: ReaderUiState?) = pair
title = uiState?.mangaName ?: getString(R.string.loading_)
viewBinding.infoBar.update(uiState)
if (uiState == null) {
supportActionBar?.subtitle = null
viewBinding.actionsView.setSliderValue(0, 1)
viewBinding.actionsView.isSliderEnabled = false
return
}
val chapterTitle = uiState.getChapterTitle(resources)
supportActionBar?.subtitle = when {
uiState.incognito -> getString(R.string.incognito_mode)
else -> chapterTitle
}
if (
settings.isReaderChapterToastEnabled &&
chapterTitle != previous?.getChapterTitle(resources) &&
chapterTitle.isNotEmpty()
) {
viewBinding.toastView.showTemporary(chapterTitle, TOAST_DURATION)
}
if (uiState.isSliderAvailable()) {
viewBinding.actionsView.setSliderValue(
value = uiState.currentPage,
max = uiState.totalPages - 1,
)
} else {
viewBinding.actionsView.setSliderValue(0, 1)
}
viewBinding.actionsView.isSliderEnabled = uiState.isSliderAvailable()
viewBinding.actionsView.isNextEnabled = uiState.hasNextChapter()
viewBinding.actionsView.isPrevEnabled = uiState.hasPreviousChapter()
}
private fun updateScrollTimerButton() {
val button = viewBinding.buttonTimer ?: return
val isButtonVisible = scrollTimer.isActive.value
&& settings.isReaderAutoscrollFabVisible
&& !viewBinding.appbarTop.isVisible
&& !viewBinding.timerControl.isVisible
if (button.isVisible != isButtonVisible) {
val transition = Fade().addTarget(button)
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
button.isVisible = isButtonVisible
}
}
// Observe foldable window layout to auto-enable double-page if configured
private fun observeWindowLayout() {
WindowInfoTracker.getOrCreate(this)
.windowLayoutInfo(this)
.onEach { info ->
val fold = info.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()
val unfolded = when (fold?.state) {
FoldingFeature.State.HALF_OPENED, FoldingFeature.State.FLAT -> true
else -> false
}
if (unfolded != isFoldUnfolded) {
isFoldUnfolded = unfolded
applyDoubleModeAuto()
}
}
.launchIn(lifecycleScope)
}
private fun askForIncognitoMode() {
buildAlertDialog(this, isCentered = true) {
var dontAskAgain = false
val listener = DialogInterface.OnClickListener { _, which ->
if (which == DialogInterface.BUTTON_NEUTRAL) {
finishAfterTransition()
} else {
viewModel.setIncognitoMode(which == DialogInterface.BUTTON_POSITIVE, dontAskAgain)
}
}
setCheckbox(R.string.dont_ask_again, dontAskAgain) { _, isChecked ->
dontAskAgain = isChecked
}
setIcon(R.drawable.ic_incognito)
setTitle(R.string.incognito_mode)
setMessage(R.string.incognito_mode_hint_nsfw)
setPositiveButton(R.string.incognito, listener)
setNegativeButton(R.string.disable, listener)
setNeutralButton(android.R.string.cancel, listener)
setOnCancelListener { finishAfterTransition() }
setCancelable(true)
}.show()
}
companion object {
private const val TOAST_DURATION = 2000L
}
}

View File

@@ -49,7 +49,7 @@ class ReaderManager(
fun setDoubleReaderMode(isEnabled: Boolean) {
val mode = currentMode
val prevReader = currentReader?.javaClass
invalidateTypesMap(isEnabled && isLandscape())
invalidateTypesMap(isEnabled)
val newReader = modeMap[mode]
if (mode != null && newReader != prevReader) {
replace(mode)

View File

@@ -10,7 +10,9 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels
import androidx.transition.TransitionManager
import com.google.android.material.button.MaterialButtonToggleGroup
import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -25,7 +27,9 @@ import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.consume
import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter
import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
@@ -34,206 +38,244 @@ import javax.inject.Inject
@AndroidEntryPoint
class ReaderConfigSheet :
BaseAdaptiveSheet<SheetReaderConfigBinding>(),
View.OnClickListener,
MaterialButtonToggleGroup.OnButtonCheckedListener,
CompoundButton.OnCheckedChangeListener {
BaseAdaptiveSheet<SheetReaderConfigBinding>(),
View.OnClickListener,
MaterialButtonToggleGroup.OnButtonCheckedListener,
CompoundButton.OnCheckedChangeListener,
Slider.OnChangeListener {
private val viewModel by activityViewModels<ReaderViewModel>()
private val viewModel by activityViewModels<ReaderViewModel>()
@Inject
lateinit var orientationHelper: ScreenOrientationHelper
@Inject
lateinit var orientationHelper: ScreenOrientationHelper
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
@Inject
lateinit var pageLoader: PageLoader
@Inject
lateinit var pageLoader: PageLoader
private lateinit var mode: ReaderMode
private lateinit var imageServerDelegate: ImageServerDelegate
private lateinit var mode: ReaderMode
private lateinit var imageServerDelegate: ImageServerDelegate
@Inject
lateinit var settings: AppSettings
@Inject
lateinit var settings: AppSettings
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mode = arguments?.getInt(AppRouter.KEY_READER_MODE)
?.let { ReaderMode.valueOf(it) }
?: ReaderMode.STANDARD
imageServerDelegate = ImageServerDelegate(
mangaRepositoryFactory = mangaRepositoryFactory,
mangaSource = viewModel.getMangaOrNull()?.source,
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mode = arguments?.getInt(AppRouter.KEY_READER_MODE)
?.let { ReaderMode.valueOf(it) }
?: ReaderMode.STANDARD
imageServerDelegate = ImageServerDelegate(
mangaRepositoryFactory = mangaRepositoryFactory,
mangaSource = viewModel.getMangaOrNull()?.source,
)
}
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
): SheetReaderConfigBinding {
return SheetReaderConfigBinding.inflate(inflater, container, false)
}
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
): SheetReaderConfigBinding {
return SheetReaderConfigBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(
binding: SheetReaderConfigBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState)
observeScreenOrientation()
binding.buttonStandard.isChecked = mode == ReaderMode.STANDARD
binding.buttonReversed.isChecked = mode == ReaderMode.REVERSED
binding.buttonWebtoon.isChecked = mode == ReaderMode.WEBTOON
binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL
binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape
binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED
override fun onViewBindingCreated(
binding: SheetReaderConfigBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState)
observeScreenOrientation()
binding.buttonStandard.isChecked = mode == ReaderMode.STANDARD
binding.buttonReversed.isChecked = mode == ReaderMode.REVERSED
binding.buttonWebtoon.isChecked = mode == ReaderMode.WEBTOON
binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL
binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape
binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED
binding.switchDoubleFoldable.isChecked = settings.isReaderDoubleOnFoldable
binding.switchDoubleFoldable.isEnabled = binding.switchDoubleReader.isEnabled
binding.sliderDoubleSensitivity.setValueRounded(settings.readerDoublePagesSensitivity * 100f)
binding.sliderDoubleSensitivity.setLabelFormatter(IntPercentLabelFormatter(binding.root.context))
binding.adjustSensitivitySlider(withAnimation = false)
binding.checkableGroup.addOnButtonCheckedListener(this)
binding.buttonSavePage.setOnClickListener(this)
binding.buttonScreenRotate.setOnClickListener(this)
binding.buttonSettings.setOnClickListener(this)
binding.buttonImageServer.setOnClickListener(this)
binding.buttonColorFilter.setOnClickListener(this)
binding.buttonScrollTimer.setOnClickListener(this)
binding.buttonBookmark.setOnClickListener(this)
binding.switchDoubleReader.setOnCheckedChangeListener(this)
binding.checkableGroup.addOnButtonCheckedListener(this)
binding.buttonSavePage.setOnClickListener(this)
binding.buttonScreenRotate.setOnClickListener(this)
binding.buttonSettings.setOnClickListener(this)
binding.buttonImageServer.setOnClickListener(this)
binding.buttonColorFilter.setOnClickListener(this)
binding.buttonScrollTimer.setOnClickListener(this)
binding.buttonBookmark.setOnClickListener(this)
binding.switchDoubleReader.setOnCheckedChangeListener(this)
binding.switchDoubleFoldable.setOnCheckedChangeListener(this)
binding.sliderDoubleSensitivity.addOnChangeListener(this)
viewModel.isBookmarkAdded.observe(viewLifecycleOwner) {
binding.buttonBookmark.setText(if (it) R.string.bookmark_remove else R.string.bookmark_add)
binding.buttonBookmark.setCompoundDrawablesRelativeWithIntrinsicBounds(
if (it) R.drawable.ic_bookmark_checked else R.drawable.ic_bookmark, 0, 0, 0,
)
}
viewModel.isBookmarkAdded.observe(viewLifecycleOwner) {
binding.buttonBookmark.setText(if (it) R.string.bookmark_remove else R.string.bookmark_add)
binding.buttonBookmark.setCompoundDrawablesRelativeWithIntrinsicBounds(
if (it) R.drawable.ic_bookmark_checked else R.drawable.ic_bookmark, 0, 0, 0,
)
}
viewLifecycleScope.launch {
val isAvailable = imageServerDelegate.isAvailable()
if (isAvailable) {
bindImageServerTitle()
}
binding.buttonImageServer.isVisible = isAvailable
}
}
viewLifecycleScope.launch {
val isAvailable = imageServerDelegate.isAvailable()
if (isAvailable) {
bindImageServerTitle()
}
binding.buttonImageServer.isVisible = isAvailable
}
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val typeMask = WindowInsetsCompat.Type.systemBars()
viewBinding?.scrollView?.updatePadding(
bottom = insets.getInsets(typeMask).bottom,
)
return insets.consume(v, typeMask, bottom = true)
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val typeMask = WindowInsetsCompat.Type.systemBars()
viewBinding?.scrollView?.updatePadding(
bottom = insets.getInsets(typeMask).bottom,
)
return insets.consume(v, typeMask, bottom = true)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_settings -> {
router.openReaderSettings()
dismissAllowingStateLoss()
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_settings -> {
router.openReaderSettings()
dismissAllowingStateLoss()
}
R.id.button_scroll_timer -> {
findParentCallback(Callback::class.java)?.onScrollTimerClick(false) ?: return
dismissAllowingStateLoss()
}
R.id.button_scroll_timer -> {
findParentCallback(Callback::class.java)?.onScrollTimerClick(false) ?: return
dismissAllowingStateLoss()
}
R.id.button_save_page -> {
findParentCallback(Callback::class.java)?.onSavePageClick() ?: return
dismissAllowingStateLoss()
}
R.id.button_save_page -> {
findParentCallback(Callback::class.java)?.onSavePageClick() ?: return
dismissAllowingStateLoss()
}
R.id.button_screen_rotate -> {
orientationHelper.isLandscape = !orientationHelper.isLandscape
}
R.id.button_screen_rotate -> {
orientationHelper.isLandscape = !orientationHelper.isLandscape
}
R.id.button_bookmark -> {
viewModel.toggleBookmark()
}
R.id.button_bookmark -> {
viewModel.toggleBookmark()
}
R.id.button_color_filter -> {
val page = viewModel.getCurrentPage() ?: return
val manga = viewModel.getMangaOrNull() ?: return
router.openColorFilterConfig(manga, page)
}
R.id.button_color_filter -> {
val page = viewModel.getCurrentPage() ?: return
val manga = viewModel.getMangaOrNull() ?: return
router.openColorFilterConfig(manga, page)
}
R.id.button_image_server -> viewLifecycleScope.launch {
if (imageServerDelegate.showDialog(v.context)) {
bindImageServerTitle()
pageLoader.invalidate(clearCache = true)
viewModel.switchChapterBy(0)
}
}
}
}
R.id.button_image_server -> viewLifecycleScope.launch {
if (imageServerDelegate.showDialog(v.context)) {
bindImageServerTitle()
pageLoader.invalidate(clearCache = true)
viewModel.switchChapterBy(0)
}
}
}
}
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
when (buttonView.id) {
R.id.switch_screen_lock_rotation -> {
orientationHelper.isLocked = isChecked
}
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
when (buttonView.id) {
R.id.switch_screen_lock_rotation -> {
orientationHelper.isLocked = isChecked
}
R.id.switch_double_reader -> {
settings.isReaderDoubleOnLandscape = isChecked
findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked)
}
}
}
R.id.switch_double_reader -> {
settings.isReaderDoubleOnLandscape = isChecked
viewBinding?.adjustSensitivitySlider(withAnimation = true)
findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked)
}
override fun onButtonChecked(
group: MaterialButtonToggleGroup?,
checkedId: Int,
isChecked: Boolean,
) {
if (!isChecked) {
return
}
val newMode = when (checkedId) {
R.id.button_standard -> ReaderMode.STANDARD
R.id.button_webtoon -> ReaderMode.WEBTOON
R.id.button_reversed -> ReaderMode.REVERSED
R.id.button_vertical -> ReaderMode.VERTICAL
else -> return
}
viewBinding?.switchDoubleReader?.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED
if (newMode == mode) {
return
}
findParentCallback(Callback::class.java)?.onReaderModeChanged(newMode) ?: return
mode = newMode
}
R.id.switch_double_foldable -> {
settings.isReaderDoubleOnFoldable = isChecked
// Re-evaluate double-page considering foldable state and current manual toggle
findParentCallback(Callback::class.java)?.onDoubleModeChanged(settings.isReaderDoubleOnLandscape)
}
}
}
private fun observeScreenOrientation() {
orientationHelper.observeAutoOrientation()
.onEach {
with(requireViewBinding()) {
buttonScreenRotate.isGone = it
switchScreenLockRotation.isVisible = it
updateOrientationLockSwitch()
}
}.launchIn(viewLifecycleScope)
}
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
settings.readerDoublePagesSensitivity = value / 100f
}
private fun updateOrientationLockSwitch() {
val switch = viewBinding?.switchScreenLockRotation ?: return
switch.setOnCheckedChangeListener(null)
switch.isChecked = orientationHelper.isLocked
switch.setOnCheckedChangeListener(this)
}
override fun onButtonChecked(
group: MaterialButtonToggleGroup?,
checkedId: Int,
isChecked: Boolean,
) {
if (!isChecked) {
return
}
val newMode = when (checkedId) {
R.id.button_standard -> ReaderMode.STANDARD
R.id.button_webtoon -> ReaderMode.WEBTOON
R.id.button_reversed -> ReaderMode.REVERSED
R.id.button_vertical -> ReaderMode.VERTICAL
else -> return
}
viewBinding?.run {
switchDoubleReader.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED
switchDoubleFoldable.isEnabled = switchDoubleReader.isEnabled
adjustSensitivitySlider(withAnimation = true)
}
if (newMode == mode) {
return
}
findParentCallback(Callback::class.java)?.onReaderModeChanged(newMode) ?: return
mode = newMode
}
private suspend fun bindImageServerTitle() {
viewBinding?.buttonImageServer?.text = getString(
R.string.inline_preference_pattern,
getString(R.string.image_server),
imageServerDelegate.getValue() ?: getString(R.string.automatic),
)
}
private fun observeScreenOrientation() {
orientationHelper.observeAutoOrientation()
.onEach {
with(requireViewBinding()) {
buttonScreenRotate.isGone = it
switchScreenLockRotation.isVisible = it
updateOrientationLockSwitch()
}
}.launchIn(viewLifecycleScope)
}
interface Callback {
private fun updateOrientationLockSwitch() {
val switch = viewBinding?.switchScreenLockRotation ?: return
switch.setOnCheckedChangeListener(null)
switch.isChecked = orientationHelper.isLocked
switch.setOnCheckedChangeListener(this)
}
fun onReaderModeChanged(mode: ReaderMode)
private suspend fun bindImageServerTitle() {
viewBinding?.buttonImageServer?.text = getString(
R.string.inline_preference_pattern,
getString(R.string.image_server),
imageServerDelegate.getValue() ?: getString(R.string.automatic),
)
}
fun onDoubleModeChanged(isEnabled: Boolean)
private fun SheetReaderConfigBinding.adjustSensitivitySlider(withAnimation: Boolean) {
val isSubOptionsVisible = switchDoubleReader.isEnabled && switchDoubleReader.isChecked
val needTransition = withAnimation && (
(isSubOptionsVisible != sliderDoubleSensitivity.isVisible) ||
(isSubOptionsVisible != textDoubleSensitivity.isVisible) ||
(isSubOptionsVisible != switchDoubleFoldable.isVisible)
)
if (needTransition) {
TransitionManager.beginDelayedTransition(layoutMain)
}
sliderDoubleSensitivity.isVisible = isSubOptionsVisible
textDoubleSensitivity.isVisible = isSubOptionsVisible
switchDoubleFoldable.isVisible = isSubOptionsVisible
}
fun onSavePageClick()
interface Callback {
fun onScrollTimerClick(isLongClick: Boolean)
fun onReaderModeChanged(mode: ReaderMode)
fun onBookmarkClick()
}
fun onDoubleModeChanged(isEnabled: Boolean)
fun onSavePageClick()
fun onScrollTimerClick(isLongClick: Boolean)
fun onBookmarkClick()
}
}

View File

@@ -25,11 +25,26 @@ abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>(), ZoomCont
readerAdapter = onCreateAdapter()
viewModel.content.observe(viewLifecycleOwner) {
if (it.state == null && it.pages.isNotEmpty() && readerAdapter?.hasItems != true) {
onPagesChanged(it.pages, viewModel.getCurrentState())
} else {
onPagesChanged(it.pages, it.state)
// Determine which state to use for restoring position:
// - content.state: explicitly set state (e.g., after mode switch or chapter change)
// - getCurrentState(): current reading position saved in SavedStateHandle
val currentState = viewModel.getCurrentState()
val pendingState = when {
// If content.state is null and we have pages, use getCurrentState
it.state == null
&& it.pages.isNotEmpty()
&& readerAdapter?.hasItems != true -> currentState
// use currentState only if it matches the current pages (to avoid the error message)
readerAdapter?.hasItems != true
&& it.state != currentState
&& currentState != null
&& it.pages.any { page -> page.chapterId == currentState.chapterId } -> currentState
// Otherwise, use content.state (normal flow, mode switch, chapter change)
else -> it.state
}
onPagesChanged(it.pages, pendingState)
}
}

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