Compare commits

...

112 Commits
v6.0.2 ... v6.2

Author SHA1 Message Date
32133d3358 Bump version 2023-10-16 17:48:25 +03:00
366e4f0da8 Disable mirror switching by default 2023-10-16 12:53:21 +03:00
3ef033c700 Update parsers 2023-10-16 12:46:39 +03:00
bef8e4652f Update acra credentials 2023-10-16 12:46:39 +03:00
8bfdf07a2f Fixes 2023-10-16 12:46:38 +03:00
Allan Nordhøy
f3e597275b Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/nb_NO/
Translation: Kotatsu/plurals
2023-10-16 12:40:38 +03:00
11feaae216 Translated using Weblate (Russian)
Currently translated at 100.0% (497 of 497 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
InfinityDouki56
fe2c1f9634 Translated using Weblate (Filipino)
Currently translated at 89.1% (443 of 497 strings)

Translated using Weblate (Filipino)

Currently translated at 89.2% (441 of 494 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
plum7x
0c7c6dc48a Translated using Weblate (Chinese (Traditional))
Currently translated at 99.5% (492 of 494 strings)

Co-authored-by: plum7x <plumgift@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
Bai
503652f024 Translated using Weblate (Turkish)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (494 of 494 strings)

Co-authored-by: Bai <batuhanakkurt000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/tr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-10-16 12:40:38 +03:00
ngocanhtve
0c4adc67ea Translated using Weblate (Vietnamese)
Currently translated at 85.1% (417 of 490 strings)

Co-authored-by: ngocanhtve <ngocanh.tve@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
BlackSpectrum
c7f5ce30b5 Translated using Weblate (Hindi)
Currently translated at 26.5% (130 of 490 strings)

Added translation using Weblate (Gujarati)

Co-authored-by: BlackSpectrum <tittan5000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
return_null
59d538824f Translated using Weblate (Chinese (Simplified))
Currently translated at 99.1% (486 of 490 strings)

Co-authored-by: return_null <demolang@dismail.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
Nayuki
de79f39d16 Translated using Weblate (Thai)
Currently translated at 71.2% (349 of 490 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
Clxff H3r4ld0
9792da3a5c Translated using Weblate (Indonesian)
Currently translated at 98.3% (482 of 490 strings)

Co-authored-by: Clxff H3r4ld0 <123844876+clxf12@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
Макар Разин
c2407e6e41 Translated using Weblate (Czech)
Currently translated at 90.1% (448 of 497 strings)

Translated using Weblate (Serbian)

Currently translated at 30.3% (151 of 497 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (497 of 497 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (497 of 497 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (497 of 497 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (490 of 490 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (490 of 490 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (490 of 490 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
gallegonovato
7321eeaed9 Translated using Weblate (Spanish)
Currently translated at 100.0% (497 of 497 strings)

Translated using Weblate (Spanish)

Currently translated at 99.7% (493 of 494 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (490 of 490 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
Cookies
9876adf676 Translated using Weblate (Vietnamese)
Currently translated at 81.3% (398 of 489 strings)

Co-authored-by: Cookies <Nekop1845@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-10-16 12:40:38 +03:00
d29e979fbf Add option to order favorites by reading progress 2023-10-13 16:31:51 +03:00
35baf4b58d Add option to order history and favorites by new chapters 2023-10-13 16:26:30 +03:00
97524d66f2 Fix pages thumbnails loading 2023-10-13 15:58:05 +03:00
5b53f8c27d Improve list options configuring 2023-10-13 14:21:28 +03:00
d4588570e6 Add option to disable new sources tip 2023-10-12 13:34:35 +03:00
cc2f9d4529 Improve chapters mapping 2023-10-12 13:24:51 +03:00
3def71ccc1 Merge branch 'feature/32-bit' into devel 2023-10-12 13:03:58 +03:00
b313c64648 Apply color config on-the-fly 2023-10-12 12:18:27 +03:00
f7e7c84317 Apply color config on-the-fly 2023-10-12 11:31:28 +03:00
ee1c532d53 Update progress 2023-10-12 10:42:28 +03:00
6993cec85e Fix new chapters counter in details screen 2023-10-12 10:42:28 +03:00
0b19f56215 Optimize finding saved manga for remote one 2023-10-12 10:42:28 +03:00
817ce7e8df 32-bit colors mode implementing 2023-10-11 21:18:13 +03:00
2b2498cb38 UI tweaks 2023-10-11 19:23:51 +03:00
e4efd0f696 Refactor manga details loading 2023-10-10 11:56:23 +03:00
fbb267e11c Update parsers 2023-10-09 10:10:55 +03:00
5740af05fa Update dependencies 2023-10-06 09:44:53 +03:00
ae2cc1dffc Add support for Dropped manga state 2023-10-04 15:48:28 +03:00
a5b9712e9f Update typography 2023-10-04 15:43:32 +03:00
c013e6e4f4 Adjust keyboard incognito mode 2023-10-04 15:35:14 +03:00
0249faa3f6 Remove bold from feed 2023-10-04 15:29:10 +03:00
9c52423dc0 Fix statusbar color 2023-10-04 15:26:14 +03:00
Eduardo Malaspina
1f7e5458ae Translated using Weblate (Spanish)
Currently translated at 100.0% (489 of 489 strings)

Co-authored-by: Eduardo Malaspina <vaio0@swismail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-10-04 15:12:03 +03:00
Макар Разин
b4d487b398 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (489 of 489 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (489 of 489 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (489 of 489 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-10-04 15:12:03 +03:00
0281f1eadb Incognito mode indicator 2023-10-04 15:00:46 +03:00
1bd9b655f9 Update parsers 2023-10-04 14:07:14 +03:00
ed87292921 Adaptive tags suggestion 2023-10-04 12:25:09 +03:00
861be7614e Fix back navigation 2023-10-04 11:44:49 +03:00
717fe8748a Fix suggestion notification text 2023-10-04 11:37:39 +03:00
c7a1312cd6 Fix check updates for saved manga #506 2023-10-02 16:49:20 +03:00
b2927854d4 Make keep screen on in reader optional 2023-10-02 16:39:14 +03:00
cfda150630 Fix crash on request pin shortcut 2023-10-02 15:22:35 +03:00
4fa1382ce9 Fix crash on download update 2023-10-02 15:15:44 +03:00
43075c52d1 Improve automatic mirror switching 2023-10-02 14:49:45 +03:00
87942747fc Update parsers 2023-10-02 13:34:40 +03:00
bb6cd73acd Update parsers 2023-09-30 17:44:13 +03:00
kuragehime
6790e5b0d4 Translated using Weblate (Japanese)
Currently translated at 100.0% (487 of 487 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-09-30 17:16:27 +03:00
Макар Разин
845c356a73 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (487 of 487 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (487 of 487 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-09-30 17:16:27 +03:00
return_null
34499ea77d Translated using Weblate (Chinese (Simplified))
Currently translated at 99.3% (484 of 487 strings)

Co-authored-by: return_null <demolang@dismail.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-09-30 17:16:27 +03:00
InfinityDouki56
6210864280 Translated using Weblate (Filipino)
Currently translated at 89.3% (435 of 487 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-09-30 17:16:27 +03:00
Crono
19084419c7 Translated using Weblate (Portuguese)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (487 of 487 strings)

Added translation using Weblate (Portuguese)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (487 of 487 strings)

Translated using Weblate (Portuguese)

Currently translated at 90.1% (439 of 487 strings)

Co-authored-by: Crono <cronoreader@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-09-30 17:16:27 +03:00
J. Lavoie
84ce4c508c Translated using Weblate (French)
Currently translated at 100.0% (487 of 487 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-09-20 12:34:26 +03:00
gallegonovato
0db8fafe61 Translated using Weblate (Spanish)
Currently translated at 100.0% (487 of 487 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-09-20 12:34:26 +03:00
fed241215e Update parsers 2023-09-20 12:34:12 +03:00
761f24daf9 Fix crashes 2023-09-20 09:40:20 +03:00
a435435496 Fix webtoon zoom controls visibility 2023-09-18 13:57:07 +03:00
81e8c25563 Reorder reader settings items 2023-09-18 13:51:02 +03:00
e3504c3b1e Translated using Weblate (Russian)
Currently translated at 100.0% (487 of 487 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-09-18 13:48:30 +03:00
Макар Разин
2601c12348 Translated using Weblate (Russian)
Currently translated at 100.0% (483 of 483 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (483 of 483 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-09-18 13:48:30 +03:00
138cf44e37 Fix crash with ActivityNotFoundException 2023-09-18 13:42:28 +03:00
65d83e0921 Fix search action #495 2023-09-18 13:34:08 +03:00
6e1cd05fa8 Zoom control buttons in reader 2023-09-18 13:25:53 +03:00
8398c01929 Improve keyboard control in reader 2023-09-18 12:49:37 +03:00
835c49ae79 Download updates directly 2023-09-15 13:34:13 +03:00
36065ccf6c Pin source shortcuts 2023-09-15 12:12:06 +03:00
4ab40566f7 Fix sync server address configuration 2023-09-15 11:14:06 +03:00
return_null
bf01a4d1ab Translated using Weblate (Chinese (Simplified))
Currently translated at 99.3% (480 of 483 strings)

Co-authored-by: return_null <demolang@dismail.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-09-14 13:41:58 +03:00
8dce9dcc3f Fix pages thumbnails loading 2023-09-14 13:38:02 +03:00
d872044252 Improve mouse interaction 2023-09-14 13:31:18 +03:00
f4313525c2 Update dependencies 2023-09-14 09:05:50 +03:00
4eb4ec7de0 Fix nsfw sources filtering 2023-09-12 19:33:49 +03:00
ecb4dd87d9 Update parsers 2023-09-12 19:28:45 +03:00
Nayuki
3d0f5f75cd Translated using Weblate (Thai)
Currently translated at 63.3% (306 of 483 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
Bander AL-shreef
c5462e8454 Translated using Weblate (Arabic)
Currently translated at 37.4% (181 of 483 strings)

Co-authored-by: Bander AL-shreef <bander.alshreef@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
return_null
5039e324fb Translated using Weblate (Chinese (Simplified))
Currently translated at 99.3% (480 of 483 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 93.1% (450 of 483 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 92.5% (447 of 483 strings)

Co-authored-by: return_null <demolang@dismail.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-09-12 18:31:26 +03:00
Макар Разин
b251b3e654 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (483 of 483 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (483 of 483 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (483 of 483 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
gallegonovato
5f10070564 Translated using Weblate (Spanish)
Currently translated at 100.0% (483 of 483 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
Clxff H3r4ld0
3da6f80eb6 Translated using Weblate (Indonesian)
Currently translated at 100.0% (481 of 481 strings)

Co-authored-by: Clxff H3r4ld0 <123844876+clxf12@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
InfinityDouki56
4b2cfdb972 Translated using Weblate (Filipino)
Currently translated at 89.7% (428 of 477 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
kuragehime
51387ace7e Translated using Weblate (Japanese)
Currently translated at 100.0% (483 of 483 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (481 of 481 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (477 of 477 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
2bdb83ff28 Fix navigation reordering 2023-09-12 13:38:03 +03:00
a1b85433ec Fix bookmarks crash #492 2023-09-12 13:38:03 +03:00
Isira Seneviratne
ca5207c658 Use ancestors and descendants extensions 2023-09-09 17:52:12 +03:00
81de6124f0 Rethrow CancellationException from TrackWorkers #489 2023-09-09 17:42:47 +03:00
a93bc0ed5b Increase max autoscroll speed 2023-09-08 13:48:34 +03:00
a1b96ebbb5 Update parsers 2023-09-08 13:30:26 +03:00
6b93e49f56 Improve loading both local and remote manga 2023-09-08 13:07:26 +03:00
c88a9dff36 Handle enter press in search view 2023-09-08 09:17:04 +03:00
ca47c475d3 Avoid passing manga chapters via extras 2023-09-07 18:15:48 +03:00
8df7fa2729 Fix crashes 2023-09-07 16:40:07 +03:00
ea34abb1d7 Fix categories reordering 2023-09-07 14:06:52 +03:00
c4ff37350c Option to move manga source to top 2023-09-07 13:27:13 +03:00
95547a8d03 Configurable main navigation 2023-09-06 14:42:00 +03:00
4c2197aa5d Option to retry captcha resolving 2023-09-05 11:26:57 +03:00
a679b6775d Exclude captcha actvity from recent 2023-09-05 10:35:32 +03:00
d3e4e97c6f Fix tracker operations parallelism 2023-09-05 10:30:20 +03:00
d1b0af85c4 Update parsers 2023-09-05 10:30:20 +03:00
ce95e0657b Translated using Weblate (Russian)
Currently translated at 100.0% (476 of 476 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-09-05 10:30:08 +03:00
Nayuki
6bb159a6d9 Translated using Weblate (Thai)
Currently translated at 57.5% (274 of 476 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2023-09-05 10:30:08 +03:00
Макар Разин
a75583f750 Translated using Weblate (Belarusian)
Currently translated at 100.0% (476 of 476 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
2023-09-05 10:30:08 +03:00
fff9df9609 Fix categories and sources reordering 2023-09-03 17:39:52 +03:00
f9609edea5 Fallback to old systemUiVisibility in reader 2023-09-03 17:06:37 +03:00
f1245742c0 Merge branch 'File_creation_time' of github.com:Isira-Seneviratne/Kotatsu into Isira-Seneviratne-File_creation_time 2023-09-01 13:43:58 +03:00
Isira Seneviratne
ded7cdb71e Obtain file creation time 2023-08-27 06:19:07 +05:30
264 changed files with 4832 additions and 1954 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 575 versionCode = 587
versionName = '6.0.2' versionName = '6.2'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner" testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
ksp { ksp {
@@ -81,7 +81,7 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:3a76504380') { implementation('com.github.KotatsuApp:kotatsu-parsers:a61e441e79') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
@@ -89,21 +89,21 @@ dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1' implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.activity:activity-ktx:1.7.2' implementation 'androidx.activity:activity-ktx:1.8.0'
implementation 'androidx.fragment:fragment-ktx:1.6.1' implementation 'androidx.fragment:fragment-ktx:1.6.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-service:2.6.1' implementation 'androidx.lifecycle:lifecycle-service:2.6.2'
implementation 'androidx.lifecycle:lifecycle-process:2.6.1' implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.1' implementation 'androidx.recyclerview:recyclerview:1.3.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.9.0' implementation 'com.google.android.material:material:1.10.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.1' implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.2'
// TODO https://issuetracker.google.com/issues/254846063 // TODO https://issuetracker.google.com/issues/254846063
implementation 'androidx.work:work-runtime-ktx:2.8.1' implementation 'androidx.work:work-runtime-ktx:2.8.1'
@@ -120,24 +120,24 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.11.0' implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
implementation 'com.squareup.okio:okio:3.5.0' implementation 'com.squareup.okio:okio:3.6.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'com.google.dagger:hilt-android:2.47' implementation 'com.google.dagger:hilt-android:2.48.1'
kapt 'com.google.dagger:hilt-compiler:2.47' kapt 'com.google.dagger:hilt-compiler:2.48.1'
implementation 'androidx.hilt:hilt-work:1.0.0' implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0' kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.4.0' implementation 'io.coil-kt:coil-base:2.4.0'
implementation 'io.coil-kt:coil-svg:2.4.0' implementation 'io.coil-kt:coil-svg:2.4.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:9b1d20be67' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:cf089a264d'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2' implementation 'io.noties.markwon:core:4.6.2'
implementation 'ch.acra:acra-http:5.11.1' implementation 'ch.acra:acra-http:5.11.2'
implementation 'ch.acra:acra-dialog:5.11.1' implementation 'ch.acra:acra-dialog:5.11.2'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
@@ -155,6 +155,6 @@ dependencies {
androidTestImplementation 'androidx.room:room-testing:2.5.2' androidTestImplementation 'androidx.room:room-testing:2.5.2'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0' androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.47' androidTestImplementation 'com.google.dagger:hilt-android-testing:2.48.1'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.47' kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.48.1'
} }

View File

@@ -19,6 +19,7 @@
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" /> <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" /> android:maxSdkVersion="29" />
@@ -95,7 +96,12 @@
android:label="@string/search" /> android:label="@string/search" />
<activity <activity
android:name="org.koitharu.kotatsu.search.ui.MangaListActivity" android:name="org.koitharu.kotatsu.search.ui.MangaListActivity"
android:label="@string/search_manga" /> android:exported="true"
android:label="@string/manga_list">
<intent-filter>
<action android:name="${applicationId}.action.EXPLORE_MANGA" />
</intent-filter>
</activity>
<activity <activity
android:name="org.koitharu.kotatsu.history.ui.HistoryActivity" android:name="org.koitharu.kotatsu.history.ui.HistoryActivity"
android:label="@string/history" /> android:label="@string/history" />
@@ -138,6 +144,7 @@
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name="org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity" android:name="org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity"
android:autoRemoveFromRecents="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity <activity
@@ -313,6 +320,13 @@
android:name="android.appwidget.provider" android:name="android.appwidget.provider"
android:resource="@xml/widget_recent" /> android:resource="@xml/widget_recent" />
</receiver> </receiver>
<receiver
android:name="org.koitharu.kotatsu.settings.about.UpdateDownloadReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
</intent-filter>
</receiver>
<meta-data <meta-data
android:name="android.webkit.WebView.EnableSafeBrowsing" android:name="android.webkit.WebView.EnableSafeBrowsing"

View File

@@ -14,7 +14,6 @@ import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
@@ -25,8 +24,7 @@ import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.ui.util.reverseAsync
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
@@ -38,7 +36,6 @@ import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import javax.inject.Inject import javax.inject.Inject
@@ -61,11 +58,17 @@ class BookmarksFragment :
private var bookmarksAdapter: BookmarksAdapter? = null private var bookmarksAdapter: BookmarksAdapter? = null
private var selectionController: ListSelectionController? = null private var selectionController: ListSelectionController? = null
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentListSimpleBinding { override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
): FragmentListSimpleBinding {
return FragmentListSimpleBinding.inflate(inflater, container, false) return FragmentListSimpleBinding.inflate(inflater, container, false)
} }
override fun onViewBindingCreated(binding: FragmentListSimpleBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(
binding: FragmentListSimpleBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
selectionController = ListSelectionController( selectionController = ListSelectionController(
activity = requireActivity(), activity = requireActivity(),
@@ -95,8 +98,11 @@ class BookmarksFragment :
viewModel.content.observe(viewLifecycleOwner) { viewModel.content.observe(viewLifecycleOwner) {
bookmarksAdapter?.setItems(it, spanSizeLookup) bookmarksAdapter?.setItems(it, spanSizeLookup)
} }
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.onError.observeEvent(
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ::onActionDone) viewLifecycleOwner,
SnackbarErrorObserver(binding.recyclerView, this)
)
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -139,12 +145,20 @@ class BookmarksFragment :
requireViewBinding().recyclerView.invalidateItemDecorations() requireViewBinding().recyclerView.invalidateItemDecorations()
} }
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { override fun onCreateActionMode(
controller: ListSelectionController,
mode: ActionMode,
menu: Menu,
): Boolean {
mode.menuInflater.inflate(R.menu.mode_bookmarks, menu) mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
return true return true
} }
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { override fun onActionItemClicked(
controller: ListSelectionController,
mode: ActionMode,
item: MenuItem,
): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.action_remove -> { R.id.action_remove -> {
val ids = selectionController?.snapshot() ?: return false val ids = selectionController?.snapshot() ?: return false
@@ -167,16 +181,6 @@ class BookmarksFragment :
} }
} }
private fun onActionDone(action: ReversibleAction) {
val handle = action.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
val snackbar = Snackbar.make((activity as SnackbarOwner).snackbarHost, action.stringResId, length)
if (handle != null) {
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
}
snackbar.show()
}
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable { private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable {
init { init {
@@ -185,7 +189,8 @@ class BookmarksFragment :
} }
override fun getSpanSize(position: Int): Int { override fun getSpanSize(position: Int): Int {
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1 val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount
?: return 1
return when (bookmarksAdapter?.getItemViewType(position)) { return when (bookmarksAdapter?.getItemViewType(position)) {
ListItemType.PAGE_THUMB.ordinal -> 1 ListItemType.PAGE_THUMB.ordinal -> 1
else -> total else -> total
@@ -200,6 +205,12 @@ class BookmarksFragment :
companion object { companion object {
@Deprecated(
"", ReplaceWith(
"BookmarksFragment()",
"org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment"
)
)
fun newInstance() = BookmarksFragment() fun newInstance() = BookmarksFragment()
} }
} }

View File

@@ -162,7 +162,7 @@ class BookmarksSheet :
fun show(fm: FragmentManager, manga: Manga) { fun show(fm: FragmentManager, manga: Manga) {
BookmarksSheet().withArgs(1) { BookmarksSheet().withArgs(1) {
putParcelable(ARG_MANGA, ParcelableManga(manga, withChapters = true)) putParcelable(ARG_MANGA, ParcelableManga(manga))
}.showDistinct(fm, TAG) }.showDistinct(fm, TAG)
} }
} }

View File

@@ -35,6 +35,7 @@ class BookmarksSheetViewModel @Inject constructor(
val content: StateFlow<List<ListModel>> = bookmarksRepository.observeBookmarks(manga) val content: StateFlow<List<ListModel>> = bookmarksRepository.observeBookmarks(manga)
.map { mapList(it) } .map { mapList(it) }
.withErrorHandling()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingFooter())) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingFooter()))
private suspend fun mapList(bookmarks: List<Bookmark>): List<ListModel> { private suspend fun mapList(bookmarks: List<Bookmark>): List<ListModel> {

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.browser.cloudflare
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.webkit.CookieManager import android.webkit.CookieManager
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
@@ -11,8 +12,14 @@ import androidx.core.net.toUri
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.yield
import okhttp3.Headers import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
@@ -38,7 +45,13 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) { if (!catchingWebViewUnavailability {
setContentView(
ActivityBrowserBinding.inflate(
layoutInflater
)
)
}) {
return return
} }
supportActionBar?.run { supportActionBar?.run {
@@ -86,6 +99,11 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
viewBinding.webView.restoreState(savedInstanceState) viewBinding.webView.restoreState(savedInstanceState)
} }
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.opt_captcha, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.appbar.updatePadding( viewBinding.appbar.updatePadding(
top = insets.top, top = insets.top,
@@ -104,6 +122,19 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
true true
} }
R.id.action_retry -> {
lifecycleScope.launch {
viewBinding.webView.stopLoading()
yield()
val targetUrl = intent?.dataString?.toHttpUrlOrNull()
if (targetUrl != null) {
clearCfCookies(targetUrl)
viewBinding.webView.loadUrl(targetUrl.toString())
}
}
true
}
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
@@ -141,7 +172,15 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) { override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
setTitle(title) setTitle(title)
supportActionBar?.subtitle = subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle supportActionBar?.subtitle =
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
}
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
cookieJar.removeCookies(url) { cookie ->
val name = cookie.name
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf")
}
} }
class Contract : ActivityResultContract<Pair<String, Headers?>, TaggedActivityResult>() { class Contract : ActivityResultContract<Pair<String, Headers?>, TaggedActivityResult>() {

View File

@@ -30,7 +30,8 @@ import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.StubContentCache import org.koitharu.kotatsu.core.cache.StubContentCache
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.network.* import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
@@ -40,7 +41,6 @@ import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.util.AcraScreenLogger import org.koitharu.kotatsu.core.util.AcraScreenLogger
import org.koitharu.kotatsu.core.util.IncognitoModeIndicator import org.koitharu.kotatsu.core.util.IncognitoModeIndicator
import org.koitharu.kotatsu.core.util.ext.activityManager
import org.koitharu.kotatsu.core.util.ext.connectivityManager import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
@@ -161,7 +161,7 @@ interface AppModule {
fun provideContentCache( fun provideContentCache(
application: Application, application: Application,
): ContentCache { ): ContentCache {
return if (application.activityManager?.isLowRamDevice == true) { return if (application.isLowRamDevice()) {
StubContentCache() StubContentCache()
} else { } else {
MemoryContentCache(application) MemoryContentCache(application)

View File

@@ -6,6 +6,7 @@ import androidx.room.InvalidationTracker
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration import androidx.room.migration.Migration
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -118,7 +119,7 @@ fun MangaDatabase(context: Context): MangaDatabase = Room
fun InvalidationTracker.removeObserverAsync(observer: InvalidationTracker.Observer) { fun InvalidationTracker.removeObserverAsync(observer: InvalidationTracker.Observer) {
val scope = processLifecycleScope val scope = processLifecycleScope
if (scope.isActive) { if (scope.isActive) {
processLifecycleScope.launch(Dispatchers.Default) { processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) {
removeObserver(observer) removeObserver(observer)
} }
} }

View File

@@ -6,3 +6,4 @@ const val TABLE_TAGS = "tags"
const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories" const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories"
const val TABLE_HISTORY = "history" const val TABLE_HISTORY = "history"
const val TABLE_MANGA_TAGS = "manga_tags" const val TABLE_MANGA_TAGS = "manga_tags"
const val TABLE_SOURCES = "sources"

View File

@@ -51,6 +51,28 @@ abstract class TagsDao {
) )
abstract suspend fun findTags(query: String, limit: Int): List<TagEntity> abstract suspend fun findTags(query: String, limit: Int): List<TagEntity>
@Query(
"""
SELECT tags.* FROM manga_tags
LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id
WHERE manga_tags.manga_id IN (SELECT manga_id FROM manga_tags WHERE tag_id = :tagId)
GROUP BY tags.tag_id
ORDER BY COUNT(manga_id) DESC;
""",
)
abstract suspend fun findRelatedTags(tagId: Long): List<TagEntity>
@Query(
"""
SELECT tags.* FROM manga_tags
LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id
WHERE manga_tags.manga_id IN (SELECT manga_id FROM manga_tags WHERE tag_id IN (:ids))
GROUP BY tags.tag_id
ORDER BY COUNT(manga_id) DESC;
""",
)
abstract suspend fun findRelatedTags(ids: Set<Long>): List<TagEntity>
@Upsert @Upsert
abstract suspend fun upsert(tags: Iterable<TagEntity>) abstract suspend fun upsert(tags: Iterable<TagEntity>)
} }

View File

@@ -19,6 +19,8 @@ fun TagEntity.toMangaTag() = MangaTag(
fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag) fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
fun Collection<TagEntity>.toMangaTagsList() = map(TagEntity::toMangaTag)
fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga( fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
id = this.id, id = this.id,
title = this.title, title = this.title,

View File

@@ -3,9 +3,10 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.TABLE_SOURCES
@Entity( @Entity(
tableName = "sources", tableName = TABLE_SOURCES,
) )
data class MangaSourceEntity( data class MangaSourceEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)

View File

@@ -2,9 +2,9 @@ package org.koitharu.kotatsu.core.model
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.Date import java.util.Date
@Parcelize @Parcelize
@@ -12,7 +12,7 @@ data class FavouriteCategory(
val id: Long, val id: Long,
val title: String, val title: String,
val sortKey: Int, val sortKey: Int,
val order: SortOrder, val order: ListSortOrder,
val createdAt: Date, val createdAt: Date,
val isTrackingEnabled: Boolean, val isTrackingEnabled: Boolean,
val isVisibleInLibrary: Boolean, val isVisibleInLibrary: Boolean,

View File

@@ -17,6 +17,8 @@ fun Collection<Manga>.distinctById() = distinctBy { it.id }
@JvmName("chaptersIds") @JvmName("chaptersIds")
fun Collection<MangaChapter>.ids() = mapToSet { it.id } fun Collection<MangaChapter>.ids() = mapToSet { it.id }
fun Collection<MangaChapter>.findById(id: Long) = find { x -> x.id == id }
fun Collection<ChapterListItem>.countChaptersByBranch(): Int { fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) { if (size <= 1) {
return size return size
@@ -30,7 +32,7 @@ fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
} }
fun Manga.findChapter(id: Long): MangaChapter? { fun Manga.findChapter(id: Long): MangaChapter? {
return chapters?.find { it.id == id } return chapters?.findById(id)
} }
fun Manga.getPreferredBranch(history: MangaHistory?): String? { fun Manga.getPreferredBranch(history: MangaHistory?): String? {
@@ -39,7 +41,7 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
return null return null
} }
if (history != null) { if (history != null) {
val currentChapter = ch.find { it.id == history.chapterId } val currentChapter = ch.findById(history.chapterId)
if (currentChapter != null) { if (currentChapter != null) {
return currentChapter.branch return currentChapter.branch
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.core.model
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale import java.util.Locale
@@ -15,3 +16,5 @@ fun MangaSource(name: String): MangaSource {
} }
return MangaSource.DUMMY return MangaSource.DUMMY
} }
fun MangaSource.isNsfw() = contentType == ContentType.HENTAI

View File

@@ -0,0 +1,42 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
@Parcelize
data class ParcelableChapter(
val chapter: MangaChapter,
) : Parcelable {
companion object : Parceler<ParcelableChapter> {
override fun create(parcel: Parcel) = ParcelableChapter(
MangaChapter(
id = parcel.readLong(),
name = parcel.readString().orEmpty(),
number = parcel.readInt(),
url = parcel.readString().orEmpty(),
scanlator = parcel.readString(),
uploadDate = parcel.readLong(),
branch = parcel.readString(),
source = parcel.readSerializableCompat() ?: MangaSource.DUMMY,
)
)
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
parcel.writeLong(id)
parcel.writeString(name)
parcel.writeInt(number)
parcel.writeString(url)
parcel.writeString(scanlator)
parcel.writeLong(uploadDate)
parcel.writeString(branch)
parcel.writeSerializable(source)
}
}
}

View File

@@ -9,55 +9,28 @@ import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
// Limits to avoid TransactionTooLargeException
private const val MAX_SAFE_SIZE = 1024 * 100 // Assume that 100 kb is safe parcel size
private const val MAX_SAFE_CHAPTERS_COUNT = 24 // this is 100% safe
@Parcelize @Parcelize
data class ParcelableManga( data class ParcelableManga(
val manga: Manga, val manga: Manga,
private val withChapters: Boolean,
) : Parcelable { ) : Parcelable {
companion object : Parceler<ParcelableManga> {
private fun Manga.writeToParcel(out: Parcel, flags: Int, withChapters: Boolean) {
out.writeLong(id)
out.writeString(title)
out.writeString(altTitle)
out.writeString(url)
out.writeString(publicUrl)
out.writeFloat(rating)
ParcelCompat.writeBoolean(out, isNsfw)
out.writeString(coverUrl)
out.writeString(largeCoverUrl)
out.writeString(description)
out.writeParcelable(ParcelableMangaTags(tags), flags)
out.writeSerializable(state)
out.writeString(author)
val parcelableChapters = if (withChapters) null else chapters?.let(::ParcelableMangaChapters)
out.writeParcelable(parcelableChapters, flags)
out.writeSerializable(source)
}
override fun ParcelableManga.write(parcel: Parcel, flags: Int) { companion object : Parceler<ParcelableManga> {
val chapters = manga.chapters
if (!withChapters || chapters == null) { override fun ParcelableManga.write(parcel: Parcel, flags: Int) = with(manga) {
manga.writeToParcel(parcel, flags, withChapters = false) parcel.writeLong(id)
return parcel.writeString(title)
} parcel.writeString(altTitle)
if (chapters.size <= MAX_SAFE_CHAPTERS_COUNT) { parcel.writeString(url)
// fast path parcel.writeString(publicUrl)
manga.writeToParcel(parcel, flags, withChapters = true) parcel.writeFloat(rating)
return ParcelCompat.writeBoolean(parcel, isNsfw)
} parcel.writeString(coverUrl)
val tempParcel = Parcel.obtain() parcel.writeString(largeCoverUrl)
manga.writeToParcel(tempParcel, flags, withChapters = true) parcel.writeString(description)
val size = tempParcel.dataSize() parcel.writeParcelable(ParcelableMangaTags(tags), flags)
if (size < MAX_SAFE_SIZE) { parcel.writeSerializable(state)
parcel.appendFrom(tempParcel, 0, size) parcel.writeString(author)
} else { parcel.writeSerializable(source)
manga.writeToParcel(parcel, flags, withChapters = false)
}
tempParcel.recycle()
} }
override fun create(parcel: Parcel) = ParcelableManga( override fun create(parcel: Parcel) = ParcelableManga(
@@ -75,10 +48,9 @@ data class ParcelableManga(
tags = requireNotNull(parcel.readParcelableCompat<ParcelableMangaTags>()).tags, tags = requireNotNull(parcel.readParcelableCompat<ParcelableMangaTags>()).tags,
state = parcel.readSerializableCompat(), state = parcel.readSerializableCompat(),
author = parcel.readString(), author = parcel.readString(),
chapters = parcel.readParcelableCompat<ParcelableMangaChapters>()?.chapters, chapters = null,
source = requireNotNull(parcel.readSerializableCompat()), source = requireNotNull(parcel.readSerializableCompat()),
), )
withChapters = true
) )
} }
} }

View File

@@ -1,37 +0,0 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.MangaChapter
object MangaChapterParceler : Parceler<MangaChapter> {
override fun create(parcel: Parcel) = MangaChapter(
id = parcel.readLong(),
name = requireNotNull(parcel.readString()),
number = parcel.readInt(),
url = requireNotNull(parcel.readString()),
scanlator = parcel.readString(),
uploadDate = parcel.readLong(),
branch = parcel.readString(),
source = requireNotNull(parcel.readSerializableCompat()),
)
override fun MangaChapter.write(parcel: Parcel, flags: Int) {
parcel.writeLong(id)
parcel.writeString(name)
parcel.writeInt(number)
parcel.writeString(url)
parcel.writeString(scanlator)
parcel.writeLong(uploadDate)
parcel.writeString(branch)
parcel.writeSerializable(source)
}
}
@Parcelize
@TypeParceler<MangaChapter, MangaChapterParceler>
data class ParcelableMangaChapters(val chapters: List<MangaChapter>) : Parcelable

View File

@@ -1,6 +1,9 @@
package org.koitharu.kotatsu.core.network package org.koitharu.kotatsu.core.network
import androidx.collection.ArraySet
import dagger.Lazy import dagger.Lazy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@@ -13,6 +16,7 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.EnumMap
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -22,9 +26,15 @@ class MirrorSwitchInterceptor @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
) : Interceptor { ) : Interceptor {
private val locks = EnumMap<MangaSource, Any>(MangaSource::class.java)
private val blacklist = EnumMap<MangaSource, MutableSet<String>>(MangaSource::class.java)
val isEnabled: Boolean
get() = settings.isMirrorSwitchingAvailable
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request() val request = chain.request()
if (!settings.isMirrorSwitchingAvailable) { if (!isEnabled) {
return chain.proceed(request) return chain.proceed(request)
} }
return try { return try {
@@ -43,6 +53,30 @@ class MirrorSwitchInterceptor @Inject constructor(
} }
} }
suspend fun trySwitchMirror(repository: RemoteMangaRepository): Boolean = runInterruptible(Dispatchers.Default) {
if (!isEnabled) {
return@runInterruptible false
}
val mirrors = repository.getAvailableMirrors()
if (mirrors.size <= 1) {
return@runInterruptible false
}
synchronized(obtainLock(repository.source)) {
val currentMirror = repository.domain
addToBlacklist(repository.source, currentMirror)
val newMirror = mirrors.firstOrNull { x ->
x != currentMirror && !isBlacklisted(repository.source, x)
} ?: return@synchronized false
repository.domain = newMirror
true
}
}
fun rollback(repository: RemoteMangaRepository, oldMirror: String) = synchronized(obtainLock(repository.source)) {
blacklist[repository.source]?.remove(oldMirror)
repository.domain = oldMirror
}
private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? { private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? {
val source = request.tag(MangaSource::class.java) ?: return null val source = request.tag(MangaSource::class.java) ?: return null
val repository = mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository ?: return null val repository = mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository ?: return null
@@ -50,7 +84,9 @@ class MirrorSwitchInterceptor @Inject constructor(
if (mirrors.isEmpty()) { if (mirrors.isEmpty()) {
return null return null
} }
return tryMirrors(repository, mirrors, chain, request) return synchronized(obtainLock(repository.source)) {
tryMirrors(repository, mirrors, chain, request)
}
} }
private fun tryMirrors( private fun tryMirrors(
@@ -66,7 +102,7 @@ class MirrorSwitchInterceptor @Inject constructor(
} }
val urlBuilder = url.newBuilder() val urlBuilder = url.newBuilder()
for (mirror in mirrors) { for (mirror in mirrors) {
if (mirror == currentDomain) { if (mirror == currentDomain || isBlacklisted(repository.source, mirror)) {
continue continue
} }
val newHost = hostOf(url.host, mirror) ?: continue val newHost = hostOf(url.host, mirror) ?: continue
@@ -75,6 +111,7 @@ class MirrorSwitchInterceptor @Inject constructor(
.build() .build()
val response = chain.proceed(newRequest) val response = chain.proceed(newRequest)
if (response.isFailed) { if (response.isFailed) {
addToBlacklist(repository.source, mirror)
response.closeQuietly() response.closeQuietly()
} else { } else {
repository.domain = mirror repository.domain = mirror
@@ -104,4 +141,18 @@ class MirrorSwitchInterceptor @Inject constructor(
private fun ResponseBody.copy(): ResponseBody { private fun ResponseBody.copy(): ResponseBody {
return source().readByteArray().toResponseBody(contentType()) return source().readByteArray().toResponseBody(contentType())
} }
private fun obtainLock(source: MangaSource): Any = locks.getOrPut(source) {
Any()
}
private fun isBlacklisted(source: MangaSource, domain: String): Boolean {
return blacklist[source]?.contains(domain) == true
}
private fun addToBlacklist(source: MangaSource, domain: String) {
blacklist.getOrPut(source) {
ArraySet(2)
}.add(domain)
}
} }

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.network.cookies
import android.webkit.CookieManager import android.webkit.CookieManager
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.core.util.Predicate
import okhttp3.Cookie import okhttp3.Cookie
import okhttp3.HttpUrl import okhttp3.HttpUrl
import org.koitharu.kotatsu.core.util.ext.newBuilder import org.koitharu.kotatsu.core.util.ext.newBuilder
@@ -31,19 +32,21 @@ class AndroidCookieJar : MutableCookieJar {
} }
} }
override fun removeCookies(url: HttpUrl) { override fun removeCookies(url: HttpUrl, predicate: Predicate<Cookie>?) {
val cookies = loadForRequest(url) val cookies = loadForRequest(url)
if (cookies.isEmpty()) { if (cookies.isEmpty()) {
return return
} }
val urlString = url.toString() val urlString = url.toString()
for (c in cookies) { for (c in cookies) {
if (predicate != null && !predicate.test(c)) {
continue
}
val nc = c.newBuilder() val nc = c.newBuilder()
.expiresAt(System.currentTimeMillis() - 100000) .expiresAt(System.currentTimeMillis() - 100000)
.build() .build()
cookieManager.setCookie(urlString, nc.toString()) cookieManager.setCookie(urlString, nc.toString())
} }
check(loadForRequest(url).isEmpty())
} }
override suspend fun clear() = suspendCoroutine<Boolean> { continuation -> override suspend fun clear() = suspendCoroutine<Boolean> { continuation ->

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.network.cookies package org.koitharu.kotatsu.core.network.cookies
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.core.util.Predicate
import okhttp3.Cookie import okhttp3.Cookie
import okhttp3.CookieJar import okhttp3.CookieJar
import okhttp3.HttpUrl import okhttp3.HttpUrl
@@ -14,7 +15,7 @@ interface MutableCookieJar : CookieJar {
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>)
@WorkerThread @WorkerThread
fun removeCookies(url: HttpUrl) fun removeCookies(url: HttpUrl, predicate: Predicate<Cookie>?)
suspend fun clear(): Boolean suspend fun clear(): Boolean
} }

View File

@@ -4,6 +4,7 @@ import android.content.Context
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.collection.ArrayMap import androidx.collection.ArrayMap
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.util.Predicate
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.Cookie import okhttp3.Cookie
@@ -57,12 +58,14 @@ class PreferencesCookieJar(
@Synchronized @Synchronized
@WorkerThread @WorkerThread
override fun removeCookies(url: HttpUrl) { override fun removeCookies(url: HttpUrl, predicate: Predicate<Cookie>?) {
loadPersistent() loadPersistent()
val toRemove = HashSet<String>() val toRemove = HashSet<String>()
for ((key, cookie) in cache) { for ((key, cookie) in cache) {
if (cookie.isExpired() || cookie.cookie.matches(url)) { if (cookie.isExpired() || cookie.cookie.matches(url)) {
toRemove += key if (predicate == null || predicate.test(cookie.cookie)) {
toRemove += key
}
} }
} }
if (toRemove.isNotEmpty()) { if (toRemove.isNotEmpty()) {

View File

@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
@@ -29,8 +30,10 @@ import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.search.ui.MangaListActivity
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -73,8 +76,18 @@ class AppShortcutManager @Inject constructor(
} }
} }
suspend fun requestPinShortcut(manga: Manga): Boolean { suspend fun requestPinShortcut(manga: Manga): Boolean = try {
return ShortcutManagerCompat.requestPinShortcut(context, buildShortcutInfo(manga), null) ShortcutManagerCompat.requestPinShortcut(context, buildShortcutInfo(manga), null)
} catch (e: IllegalStateException) {
e.printStackTraceDebug()
false
}
suspend fun requestPinShortcut(source: MangaSource): Boolean = try {
ShortcutManagerCompat.requestPinShortcut(context, buildShortcutInfo(source), null)
} catch (e: IllegalStateException) {
e.printStackTraceDebug()
false
} }
@VisibleForTesting @VisibleForTesting
@@ -86,6 +99,11 @@ class AppShortcutManager @Inject constructor(
ShortcutManagerCompat.reportShortcutUsed(context, mangaId.toString()) ShortcutManagerCompat.reportShortcutUsed(context, mangaId.toString())
} }
fun isDynamicShortcutsAvailable(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 &&
context.getSystemService(ShortcutManager::class.java).maxShortcutCountPerActivity > 0
}
private suspend fun updateShortcutsImpl() = runCatchingCancellable { private suspend fun updateShortcutsImpl() = runCatchingCancellable {
val maxShortcuts = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context).coerceAtLeast(5) val maxShortcuts = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context).coerceAtLeast(5)
val shortcuts = historyRepository.getList(0, maxShortcuts) val shortcuts = historyRepository.getList(0, maxShortcuts)
@@ -132,8 +150,25 @@ class AppShortcutManager @Inject constructor(
.build() .build()
} }
fun isDynamicShortcutsAvailable(): Boolean { private suspend fun buildShortcutInfo(source: MangaSource): ShortcutInfoCompat {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 && val icon = runCatchingCancellable {
context.getSystemService(ShortcutManager::class.java).maxShortcutCountPerActivity > 0 coil.execute(
ImageRequest.Builder(context)
.data(source.faviconUri())
.size(iconSize)
.scale(Scale.FIT)
.build(),
).getDrawableOrThrow().toBitmap()
}.fold(
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
)
return ShortcutInfoCompat.Builder(context, source.name)
.setShortLabel(source.title)
.setLongLabel(source.title)
.setIcon(icon)
.setLongLived(true)
.setIntent(MangaListActivity.newIntent(context, source))
.build()
} }
} }

View File

@@ -43,5 +43,7 @@ class MangaIntent private constructor(
const val KEY_MANGA = "manga" const val KEY_MANGA = "manga"
const val KEY_ID = "id" const val KEY_ID = "id"
fun of(manga: Manga) = MangaIntent(manga, manga.id, null)
} }
} }

View File

@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.* import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume import kotlin.coroutines.resume
@@ -50,7 +50,7 @@ class MangaLoaderContextImpl @Inject constructor(
} }
override fun encodeBase64(data: ByteArray): String { override fun encodeBase64(data: ByteArray): String {
return Base64.encodeToString(data, Base64.NO_PADDING) return Base64.encodeToString(data, Base64.NO_WRAP)
} }
override fun decodeBase64(data: String): ByteArray { override fun decodeBase64(data: String): ByteArray {

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.parser
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import org.koitharu.kotatsu.core.cache.ContentCache import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -43,6 +44,7 @@ interface MangaRepository {
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val loaderContext: MangaLoaderContext, private val loaderContext: MangaLoaderContext,
private val contentCache: ContentCache, private val contentCache: ContentCache,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
) { ) {
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java) private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
@@ -55,7 +57,11 @@ interface MangaRepository {
cache[source]?.get()?.let { return it } cache[source]?.get()?.let { return it }
return synchronized(cache) { return synchronized(cache) {
cache[source]?.get()?.let { return it } cache[source]?.get()?.let { return it }
val repository = RemoteMangaRepository(MangaParser(source, loaderContext), contentCache) val repository = RemoteMangaRepository(
parser = MangaParser(source, loaderContext),
cache = contentCache,
mirrorSwitchInterceptor = mirrorSwitchInterceptor,
)
cache[source] = WeakReference(repository) cache[source] = WeakReference(repository)
repository repository
} }

View File

@@ -13,11 +13,13 @@ import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.cache.ContentCache import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.cache.SafeDeferred import org.koitharu.kotatsu.core.cache.SafeDeferred
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.Favicons import org.koitharu.kotatsu.parsers.model.Favicons
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
@@ -31,6 +33,7 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
class RemoteMangaRepository( class RemoteMangaRepository(
private val parser: MangaParser, private val parser: MangaParser,
private val cache: ContentCache, private val cache: ContentCache,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
) : MangaRepository, Interceptor { ) : MangaRepository, Interceptor {
override val source: MangaSource override val source: MangaSource
@@ -66,11 +69,15 @@ class RemoteMangaRepository(
} }
override suspend fun getList(offset: Int, query: String): List<Manga> { override suspend fun getList(offset: Int, query: String): List<Manga> {
return parser.getList(offset, query) return mirrorSwitchInterceptor.withMirrorSwitching {
parser.getList(offset, query)
}
} }
override suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> { override suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> {
return parser.getList(offset, tags, sortOrder) return mirrorSwitchInterceptor.withMirrorSwitching {
parser.getList(offset, tags, sortOrder)
}
} }
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, withCache = true) override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, withCache = true)
@@ -78,17 +85,25 @@ class RemoteMangaRepository(
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
cache.getPages(source, chapter.url)?.let { return it } cache.getPages(source, chapter.url)?.let { return it }
val pages = asyncSafe { val pages = asyncSafe {
parser.getPages(chapter).distinctById() mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPages(chapter).distinctById()
}
} }
cache.putPages(source, chapter.url, pages) cache.putPages(source, chapter.url, pages)
return pages.await() return pages.await()
} }
override suspend fun getPageUrl(page: MangaPage): String = parser.getPageUrl(page) override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPageUrl(page)
}
override suspend fun getTags(): Set<MangaTag> = parser.getTags() override suspend fun getTags(): Set<MangaTag> = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getTags()
}
suspend fun getFavicons(): Favicons = parser.getFavicons() suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getFavicons()
}
override suspend fun getRelated(seed: Manga): List<Manga> { override suspend fun getRelated(seed: Manga): List<Manga> {
cache.getRelatedManga(source, seed.url)?.let { return it } cache.getRelatedManga(source, seed.url)?.let { return it }
@@ -105,7 +120,9 @@ class RemoteMangaRepository(
} }
cache.getDetails(source, manga.url)?.let { return it } cache.getDetails(source, manga.url)?.let { return it }
val details = asyncSafe { val details = asyncSafe {
parser.getDetails(manga) mirrorSwitchInterceptor.withMirrorSwitching {
parser.getDetails(manga)
}
} }
cache.putDetails(source, manga.url, details) cache.putDetails(source, manga.url, details)
return details.await() return details.await()
@@ -155,4 +172,33 @@ class RemoteMangaRepository(
} }
return result return result
} }
private suspend fun <R> MirrorSwitchInterceptor.withMirrorSwitching(block: suspend () -> R): R {
if (!isEnabled) {
return block()
}
val initialMirror = domain
val result = runCatchingCancellable {
block()
}
if (result.isValidResult()) {
return result.getOrThrow()
}
return if (trySwitchMirror(this@RemoteMangaRepository)) {
val newResult = runCatchingCancellable {
block()
}
if (newResult.isValidResult()) {
return newResult.getOrThrow()
} else {
rollback(this@RemoteMangaRepository, initialMirror)
return result.getOrThrow()
}
} else {
result.getOrThrow()
}
}
private fun Result<*>.isValidResult() = exceptionOrNull() !is ParseException
&& (getOrNull() as? Collection<*>)?.isEmpty() != true
} }

View File

@@ -22,8 +22,9 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.core.util.ext.takeIfReadable import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.history.domain.model.HistoryOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import java.io.File import java.io.File
@@ -43,7 +44,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) } set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
val theme: Int val theme: Int
get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM get() = prefs.getString(KEY_THEME, null)?.toIntOrNull()
?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
val colorScheme: ColorScheme val colorScheme: ColorScheme
get() = prefs.getEnumValue(KEY_COLOR_THEME, ColorScheme.default) get() = prefs.getEnumValue(KEY_COLOR_THEME, ColorScheme.default)
@@ -51,13 +53,37 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isAmoledTheme: Boolean val isAmoledTheme: Boolean
get() = prefs.getBoolean(KEY_THEME_AMOLED, false) get() = prefs.getBoolean(KEY_THEME_AMOLED, false)
val isFavoritesNavItemFirst: Boolean var mainNavItems: List<NavItem>
get() = (prefs.getString(KEY_FIRST_NAV_ITEM, null)?.toIntOrNull() ?: 0) == 1 get() {
val raw = prefs.getString(KEY_NAV_MAIN, null)?.split(',')
return if (raw.isNullOrEmpty()) {
listOf(NavItem.HISTORY, NavItem.FAVORITES, NavItem.EXPLORE, NavItem.FEED)
} else {
raw.mapNotNull { x -> NavItem.entries.find(x) }.ifEmpty { listOf(NavItem.EXPLORE) }
}
}
set(value) {
prefs.edit {
putString(KEY_NAV_MAIN, value.joinToString(",") { it.name })
}
}
var gridSize: Int var gridSize: Int
get() = prefs.getInt(KEY_GRID_SIZE, 100) get() = prefs.getInt(KEY_GRID_SIZE, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) } set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
var historyListMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE_HISTORY, listMode)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_HISTORY, value) }
var suggestionsListMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE_SUGGESTIONS, listMode)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_SUGGESTIONS, value) }
var favoritesListMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE_FAVORITES, listMode)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_FAVORITES, value) }
var isNsfwContentDisabled: Boolean var isNsfwContentDisabled: Boolean
get() = prefs.getBoolean(KEY_DISABLE_NSFW, false) get() = prefs.getBoolean(KEY_DISABLE_NSFW, false)
set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) } set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) }
@@ -76,6 +102,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val readerPageSwitch: Set<String> val readerPageSwitch: Set<String>
get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS) get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS)
val isReaderZoomButtonsEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_ZOOM_BUTTONS, false)
val isReaderTapsAdaptive: Boolean val isReaderTapsAdaptive: Boolean
get() = !prefs.getBoolean(KEY_READER_TAPS_LTR, false) get() = !prefs.getBoolean(KEY_READER_TAPS_LTR, false)
@@ -145,7 +174,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
var appPassword: String? var appPassword: String?
get() = prefs.getString(KEY_APP_PASSWORD, null) get() = prefs.getString(KEY_APP_PASSWORD, null)
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) } set(value) = prefs.edit {
if (value != null) putString(KEY_APP_PASSWORD, value) else remove(
KEY_APP_PASSWORD,
)
}
val isLoggingEnabled: Boolean val isLoggingEnabled: Boolean
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false) get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
@@ -155,7 +188,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) } set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
val isMirrorSwitchingAvailable: Boolean val isMirrorSwitchingAvailable: Boolean
get() = prefs.getBoolean(KEY_MIRROR_SWITCHING, true) get() = prefs.getBoolean(KEY_MIRROR_SWITCHING, false)
val isExitConfirmationEnabled: Boolean val isExitConfirmationEnabled: Boolean
get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false) get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false)
@@ -171,7 +204,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
if (isBackgroundNetworkRestricted()) { if (isBackgroundNetworkRestricted()) {
return false return false
} }
val policy = NetworkPolicy.from(prefs.getString(KEY_PREFETCH_CONTENT, null), NetworkPolicy.NEVER) val policy =
NetworkPolicy.from(prefs.getString(KEY_PREFETCH_CONTENT, null), NetworkPolicy.NEVER)
return policy.isNetworkAllowed(connectivityManager) return policy.isNetworkAllowed(connectivityManager)
} }
@@ -179,6 +213,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_SOURCES_GRID, false) get() = prefs.getBoolean(KEY_SOURCES_GRID, false)
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) } set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
val isNewSourcesTipEnabled: Boolean
get() = prefs.getBoolean(KEY_SOURCES_NEW, true)
val isPagesNumbersEnabled: Boolean val isPagesNumbersEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false) get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
@@ -248,6 +285,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderSliderEnabled: Boolean val isReaderSliderEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_SLIDER, true) get() = prefs.getBoolean(KEY_READER_SLIDER, true)
val isReaderKeepScreenOn: Boolean
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
val isImagesProxyEnabled: Boolean val isImagesProxyEnabled: Boolean
get() = prefs.getBoolean(KEY_IMAGES_PROXY, false) get() = prefs.getBoolean(KEY_IMAGES_PROXY, false)
@@ -279,8 +319,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST) get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST)
set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) } set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) }
var historySortOrder: HistoryOrder var historySortOrder: ListSortOrder
get() = prefs.getEnumValue(KEY_HISTORY_ORDER, HistoryOrder.UPDATED) get() = prefs.getEnumValue(KEY_HISTORY_ORDER, ListSortOrder.UPDATED)
set(value) = prefs.edit { putEnumValue(KEY_HISTORY_ORDER, value) } set(value) = prefs.edit { putEnumValue(KEY_HISTORY_ORDER, value) }
val isRelatedMangaEnabled: Boolean val isRelatedMangaEnabled: Boolean
@@ -292,17 +332,28 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
@get:FloatRange(from = 0.0, to = 1.0) @get:FloatRange(from = 0.0, to = 1.0)
var readerAutoscrollSpeed: Float var readerAutoscrollSpeed: Float
get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f) get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f)
set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit { putFloat(KEY_READER_AUTOSCROLL_SPEED, value) } set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit {
putFloat(
KEY_READER_AUTOSCROLL_SPEED,
value,
)
}
val isPagesPreloadEnabled: Boolean val isPagesPreloadEnabled: Boolean
get() { get() {
if (isBackgroundNetworkRestricted()) { if (isBackgroundNetworkRestricted()) {
return false return false
} }
val policy = NetworkPolicy.from(prefs.getString(KEY_PAGES_PRELOAD, null), NetworkPolicy.NON_METERED) val policy = NetworkPolicy.from(
prefs.getString(KEY_PAGES_PRELOAD, null),
NetworkPolicy.NON_METERED,
)
return policy.isNetworkAllowed(connectivityManager) return policy.isNetworkAllowed(connectivityManager)
} }
val is32BitColorsEnabled: Boolean
get() = prefs.getBoolean(KEY_32BIT_COLOR, false)
fun isTipEnabled(tip: String): Boolean { fun isTipEnabled(tip: String): Boolean {
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
} }
@@ -368,6 +419,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val TRACK_FAVOURITES = "favourites" const val TRACK_FAVOURITES = "favourites"
const val KEY_LIST_MODE = "list_mode_2" const val KEY_LIST_MODE = "list_mode_2"
const val KEY_LIST_MODE_HISTORY = "list_mode_history"
const val KEY_LIST_MODE_FAVORITES = "list_mode_favorites"
const val KEY_LIST_MODE_SUGGESTIONS = "list_mode_suggestions"
const val KEY_THEME = "theme" const val KEY_THEME = "theme"
const val KEY_COLOR_THEME = "color_theme" const val KEY_COLOR_THEME = "color_theme"
const val KEY_THEME_AMOLED = "amoled_theme" const val KEY_THEME_AMOLED = "amoled_theme"
@@ -382,6 +436,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_REMOTE_SOURCES = "remote_sources" const val KEY_REMOTE_SOURCES = "remote_sources"
const val KEY_LOCAL_STORAGE = "local_storage" const val KEY_LOCAL_STORAGE = "local_storage"
const val KEY_READER_SWITCHERS = "reader_switchers" const val KEY_READER_SWITCHERS = "reader_switchers"
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
const val KEY_TRACKER_ENABLED = "tracker_enabled" const val KEY_TRACKER_ENABLED = "tracker_enabled"
const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi" const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi"
const val KEY_TRACK_SOURCES = "track_sources" const val KEY_TRACK_SOURCES = "track_sources"
@@ -429,6 +484,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_BAR = "reader_bar" const val KEY_READER_BAR = "reader_bar"
const val KEY_READER_SLIDER = "reader_slider" const val KEY_READER_SLIDER = "reader_slider"
const val KEY_READER_BACKGROUND = "reader_background" const val KEY_READER_BACKGROUND = "reader_background"
const val KEY_READER_SCREEN_ON = "reader_screen_on"
const val KEY_SHORTCUTS = "dynamic_shortcuts" const val KEY_SHORTCUTS = "dynamic_shortcuts"
const val KEY_READER_TAPS_LTR = "reader_taps_ltr" const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
const val KEY_LOCAL_LIST_ORDER = "local_order" const val KEY_LOCAL_LIST_ORDER = "local_order"
@@ -439,6 +495,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LOGGING_ENABLED = "logging" const val KEY_LOGGING_ENABLED = "logging"
const val KEY_LOGS_SHARE = "logs_share" const val KEY_LOGS_SHARE = "logs_share"
const val KEY_SOURCES_GRID = "sources_grid" const val KEY_SOURCES_GRID = "sources_grid"
const val KEY_SOURCES_NEW = "sources_new"
const val KEY_UPDATES_UNSTABLE = "updates_unstable" const val KEY_UPDATES_UNSTABLE = "updates_unstable"
const val KEY_TIPS_CLOSED = "tips_closed" const val KEY_TIPS_CLOSED = "tips_closed"
const val KEY_SSL_BYPASS = "ssl_bypass" const val KEY_SSL_BYPASS = "ssl_bypass"
@@ -455,7 +512,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs" const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
const val KEY_DISABLE_NSFW = "no_nsfw" const val KEY_DISABLE_NSFW = "no_nsfw"
const val KEY_RELATED_MANGA = "related_manga" const val KEY_RELATED_MANGA = "related_manga"
const val KEY_FIRST_NAV_ITEM = "nav_first" const val KEY_NAV_MAIN = "nav_main"
const val KEY_32BIT_COLOR = "enhanced_colors"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

View File

@@ -0,0 +1,33 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.DrawableRes
import androidx.annotation.IdRes
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.model.ListModel
enum class NavItem(
@IdRes val id: Int,
@StringRes val title: Int,
@DrawableRes val icon: Int,
) : ListModel {
HISTORY(R.id.nav_history, R.string.history, R.drawable.ic_history_selector),
FAVORITES(R.id.nav_favorites, R.string.favourites, R.drawable.ic_favourites_selector),
LOCAL(R.id.nav_local, R.string.on_device, R.drawable.ic_storage_selector),
EXPLORE(R.id.nav_explore, R.string.explore, R.drawable.ic_explore_selector),
SUGGESTIONS(R.id.nav_suggestions, R.string.suggestions, R.drawable.ic_suggestion_selector),
FEED(R.id.nav_feed, R.string.feed, R.drawable.ic_feed_selector),
BOOKMARKS(R.id.nav_bookmarks, R.string.bookmarks, R.drawable.ic_bookmark_selector),
;
override fun areItemsTheSame(other: ListModel): Boolean {
return other is NavItem && ordinal == other.ordinal
}
fun isAvailable(settings: AppSettings): Boolean = when (this) {
SUGGESTIONS -> settings.isSuggestionsEnabled
FEED -> settings.isTrackerEnabled
else -> true
}
}

View File

@@ -5,20 +5,19 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager import android.view.WindowManager
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.util.SystemUiController
abstract class BaseFullscreenActivity<B : ViewBinding> : abstract class BaseFullscreenActivity<B : ViewBinding> :
BaseActivity<B>() { BaseActivity<B>() {
private lateinit var insetsControllerCompat: WindowInsetsControllerCompat protected lateinit var systemUiController: SystemUiController
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
with(window) { with(window) {
insetsControllerCompat = WindowInsetsControllerCompat(this, decorView) systemUiController = SystemUiController(this)
statusBarColor = Color.TRANSPARENT statusBarColor = Color.TRANSPARENT
navigationBarColor = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { navigationBarColor = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
ContextCompat.getColor(this@BaseFullscreenActivity, R.color.dim) ContextCompat.getColor(this@BaseFullscreenActivity, R.color.dim)
@@ -30,15 +29,7 @@ abstract class BaseFullscreenActivity<B : ViewBinding> :
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
} }
} }
insetsControllerCompat.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE // insetsControllerCompat.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
showSystemUI() systemUiController.setSystemUiVisible(true)
}
protected fun hideSystemUI() {
insetsControllerCompat.hide(WindowInsetsCompat.Type.systemBars())
}
protected fun showSystemUI() {
insetsControllerCompat.show(WindowInsetsCompat.Type.systemBars())
} }
} }

View File

@@ -19,13 +19,13 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes import androidx.core.content.withStyledAttributes
import androidx.core.view.GravityCompat import androidx.core.view.GravityCompat
import androidx.core.view.ancestors
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isLayoutReversed import org.koitharu.kotatsu.core.util.ext.isLayoutReversed
import org.koitharu.kotatsu.core.util.ext.parents
import org.koitharu.kotatsu.databinding.FastScrollerBinding import org.koitharu.kotatsu.databinding.FastScrollerBinding
import kotlin.math.roundToInt import kotlin.math.roundToInt
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@@ -522,7 +522,7 @@ class FastScroller @JvmOverloads constructor(
return BubbleSize.entries.getOrNull(ordinal) ?: defaultValue return BubbleSize.entries.getOrNull(ordinal) ?: defaultValue
} }
private fun findValidParent(view: View): ViewGroup? = view.parents.firstNotNullOfOrNull { p -> private fun findValidParent(view: View): ViewGroup? = view.ancestors.firstNotNullOfOrNull { p ->
if (p is FrameLayout || p is ConstraintLayout || p is CoordinatorLayout || p is RelativeLayout) { if (p is FrameLayout || p is ConstraintLayout || p is CoordinatorLayout || p is RelativeLayout) {
p as ViewGroup p as ViewGroup
} else { } else {

View File

@@ -104,6 +104,7 @@ sealed class AdaptiveSheetBehavior {
companion object { companion object {
const val STATE_EXPANDED = SideSheetBehavior.STATE_EXPANDED const val STATE_EXPANDED = SideSheetBehavior.STATE_EXPANDED
const val STATE_COLLAPSED = BottomSheetBehavior.STATE_COLLAPSED
const val STATE_SETTLING = SideSheetBehavior.STATE_SETTLING const val STATE_SETTLING = SideSheetBehavior.STATE_SETTLING
const val STATE_DRAGGING = SideSheetBehavior.STATE_DRAGGING const val STATE_DRAGGING = SideSheetBehavior.STATE_DRAGGING
const val STATE_HIDDEN = SideSheetBehavior.STATE_HIDDEN const val STATE_HIDDEN = SideSheetBehavior.STATE_HIDDEN
@@ -114,10 +115,11 @@ sealed class AdaptiveSheetBehavior {
else -> null else -> null
} }
fun from(lp: CoordinatorLayout.LayoutParams): AdaptiveSheetBehavior? = when (val behavior = lp.behavior) { fun from(lp: CoordinatorLayout.LayoutParams): AdaptiveSheetBehavior? =
is BottomSheetBehavior<*> -> Bottom(behavior) when (val behavior = lp.behavior) {
is SideSheetBehavior<*> -> Side(behavior) is BottomSheetBehavior<*> -> Bottom(behavior)
else -> null is SideSheetBehavior<*> -> Side(behavior)
} else -> null
}
} }
} }

View File

@@ -2,17 +2,19 @@ package org.koitharu.kotatsu.core.ui.sheet
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.InputDevice
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.withStyledAttributes import androidx.core.content.withStyledAttributes
import androidx.core.view.ancestors
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.parents
import org.koitharu.kotatsu.databinding.LayoutSheetHeaderAdaptiveBinding import org.koitharu.kotatsu.databinding.LayoutSheetHeaderAdaptiveBinding
class AdaptiveSheetHeaderBar @JvmOverloads constructor( class AdaptiveSheetHeaderBar @JvmOverloads constructor(
@@ -21,7 +23,8 @@ class AdaptiveSheetHeaderBar @JvmOverloads constructor(
@AttrRes defStyleAttr: Int = 0, @AttrRes defStyleAttr: Int = 0,
) : LinearLayout(context, attrs, defStyleAttr), AdaptiveSheetCallback { ) : LinearLayout(context, attrs, defStyleAttr), AdaptiveSheetCallback {
private val binding = LayoutSheetHeaderAdaptiveBinding.inflate(LayoutInflater.from(context), this) private val binding =
LayoutSheetHeaderAdaptiveBinding.inflate(LayoutInflater.from(context), this)
private var sheetBehavior: AdaptiveSheetBehavior? = null private var sheetBehavior: AdaptiveSheetBehavior? = null
var title: CharSequence? var title: CharSequence?
@@ -60,6 +63,28 @@ class AdaptiveSheetHeaderBar @JvmOverloads constructor(
super.onDetachedFromWindow() super.onDetachedFromWindow()
} }
override fun onGenericMotionEvent(event: MotionEvent): Boolean {
val behavior = sheetBehavior ?: return super.onGenericMotionEvent(event)
if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
if (event.actionMasked == MotionEvent.ACTION_SCROLL) {
if (event.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0f) {
behavior.state = if (
behavior is AdaptiveSheetBehavior.Bottom
&& behavior.state == AdaptiveSheetBehavior.STATE_EXPANDED
) {
AdaptiveSheetBehavior.STATE_COLLAPSED
} else {
AdaptiveSheetBehavior.STATE_HIDDEN
}
} else {
behavior.state = AdaptiveSheetBehavior.STATE_EXPANDED
}
return true
}
}
return super.onGenericMotionEvent(event)
}
override fun onStateChanged(sheet: View, newState: Int) { override fun onStateChanged(sheet: View, newState: Int) {
} }
@@ -81,14 +106,9 @@ class AdaptiveSheetHeaderBar @JvmOverloads constructor(
} }
private fun findParentSheetBehavior(): AdaptiveSheetBehavior? { private fun findParentSheetBehavior(): AdaptiveSheetBehavior? {
for (p in parents) { return ancestors.firstNotNullOfOrNull {
val layoutParams = (p as? View)?.layoutParams ((it as? View)?.layoutParams as? CoordinatorLayout.LayoutParams)
if (layoutParams is CoordinatorLayout.LayoutParams) { ?.let { params -> AdaptiveSheetBehavior.from(params) }
AdaptiveSheetBehavior.from(layoutParams)?.let {
return it
}
}
} }
return null
} }
} }

View File

@@ -0,0 +1,60 @@
package org.koitharu.kotatsu.core.ui.util
import android.os.Build
import android.view.View
import android.view.Window
import android.view.WindowInsets
import android.view.WindowInsetsController
import androidx.annotation.RequiresApi
sealed class SystemUiController(
protected val window: Window,
) {
abstract fun setSystemUiVisible(value: Boolean)
@RequiresApi(Build.VERSION_CODES.S)
private class Api30Impl(window: Window) : SystemUiController(window) {
private val insetsController = checkNotNull(window.decorView.windowInsetsController)
override fun setSystemUiVisible(value: Boolean) {
if (value) {
insetsController.show(WindowInsets.Type.systemBars())
insetsController.systemBarsBehavior = WindowInsetsController.BEHAVIOR_DEFAULT
} else {
insetsController.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
insetsController.hide(WindowInsets.Type.systemBars())
}
}
}
@Suppress("DEPRECATION")
private class LegacyImpl(window: Window) : SystemUiController(window) {
override fun setSystemUiVisible(value: Boolean) {
window.decorView.systemUiVisibility = if (value) {
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
} else {
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
}
}
companion object {
operator fun invoke(window: Window): SystemUiController =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
Api30Impl(window)
} else {
LegacyImpl(window)
}
}
}

View File

@@ -5,6 +5,7 @@ import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.MotionEvent import android.view.MotionEvent
import androidx.viewpager.widget.ViewPager import androidx.viewpager.widget.ViewPager
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
class EnhancedViewPager @JvmOverloads constructor( class EnhancedViewPager @JvmOverloads constructor(
@@ -25,6 +26,11 @@ class EnhancedViewPager @JvmOverloads constructor(
} }
override fun onInterceptTouchEvent(event: MotionEvent): Boolean { override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
return isUserInputEnabled && super.onInterceptTouchEvent(event) return try {
isUserInputEnabled && super.onInterceptTouchEvent(event)
} catch (e: IllegalArgumentException) {
e.printStackTraceDebug()
false
}
} }
} }

View File

@@ -65,6 +65,9 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
} }
fun show() { fun show() {
if (currentState == STATE_UP) {
return
}
currentAnimator?.cancel() currentAnimator?.cancel()
clearAnimation() clearAnimation()
@@ -77,6 +80,9 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
} }
fun hide() { fun hide() {
if (currentState == STATE_DOWN) {
return
}
currentAnimator?.cancel() currentAnimator?.cancel()
clearAnimation() clearAnimation()
@@ -117,6 +123,7 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
} }
internal class SavedState : AbsSavedState { internal class SavedState : AbsSavedState {
var currentState = STATE_UP var currentState = STATE_UP
var translationY = 0F var translationY = 0F

View File

@@ -0,0 +1,38 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ViewZoomBinding
class ZoomControl @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : LinearLayout(context, attrs), View.OnClickListener {
private val binding = ViewZoomBinding.inflate(LayoutInflater.from(context), this)
var listener: ZoomControlListener? = null
init {
binding.buttonZoomIn.setOnClickListener(this)
binding.buttonZoomOut.setOnClickListener(this)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_zoom_in -> listener?.onZoomIn()
R.id.button_zoom_out -> listener?.onZoomOut()
}
}
interface ZoomControlListener {
fun onZoomIn()
fun onZoomOut()
}
}

View File

@@ -8,6 +8,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
@Deprecated("", replaceWith = ReplaceWith("CompositeMutex2"))
class CompositeMutex<T : Any> : Set<T> { class CompositeMutex<T : Any> : Set<T> {
private val state = ArrayMap<T, MutableStateFlow<Boolean>>() private val state = ArrayMap<T, MutableStateFlow<Boolean>>()

View File

@@ -15,6 +15,7 @@ class ViewBadge(
) : View.OnLayoutChangeListener, DefaultLifecycleObserver { ) : View.OnLayoutChangeListener, DefaultLifecycleObserver {
private var badgeDrawable: BadgeDrawable? = null private var badgeDrawable: BadgeDrawable? = null
private var maxCharacterCount: Int = -1
var counter: Int var counter: Int
get() = badgeDrawable?.number ?: 0 get() = badgeDrawable?.number ?: 0
@@ -48,8 +49,16 @@ class ViewBadge(
clearBadge() clearBadge()
} }
fun setMaxCharacterCount(value: Int) {
maxCharacterCount = value
badgeDrawable?.maxCharacterCount = value
}
private fun initBadge(): BadgeDrawable { private fun initBadge(): BadgeDrawable {
val badge = BadgeDrawable.create(anchor.context) val badge = BadgeDrawable.create(anchor.context)
if (maxCharacterCount > 0) {
badge.maxCharacterCount = maxCharacterCount
}
anchor.addOnLayoutChangeListener(this) anchor.addOnLayoutChangeListener(this)
BadgeUtils.attachBadgeDrawable(badge, anchor) BadgeUtils.attachBadgeDrawable(badge, anchor)
badgeDrawable = badge badgeDrawable = badge

View File

@@ -55,3 +55,5 @@ inline fun <reified E : Enum<E>> Collection<E>.toEnumSet(): EnumSet<E> = if (isE
} else { } else {
EnumSet.copyOf(this) EnumSet.copyOf(this)
} }
fun <E : Enum<E>> Collection<E>.sortedByOrdinal() = sortedBy { it.ordinal }

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@@ -79,3 +80,11 @@ fun <T> Deferred<T>.getCompletionResultOrNull(): Result<T>? = if (isCompleted) {
} else { } else {
null null
} }
fun <T> Deferred<T>.peek(): T? = if (isCompleted) {
runCatchingCancellable {
getCompleted()
}.getOrNull()
} else {
null
}

View File

@@ -16,8 +16,10 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.fs.FileSequence import org.koitharu.kotatsu.core.fs.FileSequence
import java.io.File import java.io.File
import java.io.FileFilter import java.io.FileFilter
import java.nio.file.attribute.BasicFileAttributes
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
import kotlin.io.path.readAttributes
fun File.subdir(name: String) = File(this, name).also { fun File.subdir(name: String) = File(this, name).also {
if (!it.exists()) it.mkdirs() if (!it.exists()) it.mkdirs()
@@ -99,3 +101,10 @@ private suspend fun SequenceScope<File>.listFilesRecursiveImpl(root: File, filte
fun File.children() = FileSequence(this) fun File.children() = FileSequence(this)
fun Sequence<File>.filterWith(filter: FileFilter): Sequence<File> = filter { f -> filter.accept(f) } fun Sequence<File>.filterWith(filter: FileFilter): Sequence<File> = filter { f -> filter.accept(f) }
val File.creationTime
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
toPath().readAttributes<BasicFileAttributes>().creationTime().toMillis()
} else {
lastModified()
}

View File

@@ -5,6 +5,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
@@ -72,7 +74,7 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
flow4: Flow<T4>, flow4: Flow<T4>,
flow5: Flow<T5>, flow5: Flow<T5>,
flow6: Flow<T6>, flow6: Flow<T6>,
transform: suspend (T1, T2, T3, T4, T5, T6) -> R transform: suspend (T1, T2, T3, T4, T5, T6) -> R,
): Flow<R> = combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> -> ): Flow<R> = combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> ->
transform( transform(
args[0] as T1, args[0] as T1,
@@ -83,3 +85,7 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
args[5] as T6, args[5] as T6,
) )
} }
suspend fun <T : Any> Flow<T?>.firstNotNull(): T = checkNotNull(first { x -> x != null })
suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x != null }

View File

@@ -27,6 +27,7 @@ import java.net.SocketTimeoutException
import java.net.UnknownHostException import java.net.UnknownHostException
private const val MSG_NO_SPACE_LEFT = "No space left on device" private const val MSG_NO_SPACE_LEFT = "No space left on device"
private const val IMAGE_FORMAT_NO_SUPPORTED = "Image format not supported"
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) { fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is AuthRequiredException -> resources.getString(R.string.auth_required) is AuthRequiredException -> resources.getString(R.string.auth_required)
@@ -81,6 +82,7 @@ private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String
private fun getDisplayMessage(msg: String?, resources: Resources): String? = when { private fun getDisplayMessage(msg: String?, resources: Resources): String? = when {
msg.isNullOrEmpty() -> null msg.isNullOrEmpty() -> null
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left) msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
msg.contains(IMAGE_FORMAT_NO_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
else -> null else -> null
} }

View File

@@ -2,16 +2,18 @@ package org.koitharu.kotatsu.core.util.ext
import android.app.Activity import android.app.Activity
import android.graphics.Rect import android.graphics.Rect
import android.os.Build
import android.view.View import android.view.View
import android.view.View.MeasureSpec import android.view.View.MeasureSpec
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewParent
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.Checkable import android.widget.Checkable
import android.widget.CompoundButton
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.descendants
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.progressindicator.BaseProgressIndicator
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -88,23 +90,8 @@ fun Slider.setValueRounded(newValue: Float) {
value = roundedValue.coerceIn(valueFrom, valueTo) value = roundedValue.coerceIn(valueFrom, valueTo)
} }
fun <T : View> ViewGroup.findViewsByType(clazz: Class<T>): Sequence<T> {
if (childCount == 0) {
return emptySequence()
}
return sequence {
for (view in children) {
if (clazz.isInstance(view)) {
yield(clazz.cast(view)!!)
} else if (view is ViewGroup && view.childCount != 0) {
yieldAll(view.findViewsByType(clazz))
}
}
}
}
fun RecyclerView.invalidateNestedItemDecorations() { fun RecyclerView.invalidateNestedItemDecorations() {
findViewsByType(RecyclerView::class.java).forEach { descendants.filterIsInstance<RecyclerView>().forEach {
it.invalidateItemDecorations() it.invalidateItemDecorations()
} }
} }
@@ -112,15 +99,6 @@ fun RecyclerView.invalidateNestedItemDecorations() {
val View.parentView: ViewGroup? val View.parentView: ViewGroup?
get() = parent as? ViewGroup get() = parent as? ViewGroup
val View.parents: Sequence<ViewParent>
get() = sequence {
var p: ViewParent? = parent
while (p != null) {
yield(p)
p = p.parent
}
}
fun View.measureDimension(desiredSize: Int, measureSpec: Int): Int { fun View.measureDimension(desiredSize: Int, measureSpec: Int): Int {
var result: Int var result: Int
val specMode = MeasureSpec.getMode(measureSpec) val specMode = MeasureSpec.getMode(measureSpec)
@@ -155,3 +133,17 @@ fun TabLayout.setTabsEnabled(enabled: Boolean) {
getTabAt(i)?.view?.isEnabled = enabled getTabAt(i)?.view?.isEnabled = enabled
} }
} }
fun BaseProgressIndicator<*>.showOrHide(value: Boolean) {
if (value) {
if (!isVisible) show()
} else {
if (isVisible) hide()
}
}
fun View.setOnContextClickListenerCompat(listener: View.OnLongClickListener) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setOnContextClickListener(listener::onLongClick)
}
}

View File

@@ -0,0 +1,43 @@
package org.koitharu.kotatsu.details.data
import org.koitharu.kotatsu.core.model.isLocal
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.reader.data.filterChapters
data class MangaDetails(
private val manga: Manga,
private val localManga: LocalManga?,
val description: CharSequence?,
val isLoaded: Boolean,
) {
val id: Long
get() = manga.id
val chapters: Map<String?, List<MangaChapter>> = manga.chapters?.groupBy { it.branch }.orEmpty()
val branches: Set<String?>
get() = chapters.keys
val allChapters: List<MangaChapter>
get() = manga.chapters.orEmpty()
val isLocal
get() = manga.isLocal
val local: LocalManga?
get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null
fun toManga() = manga
fun filterChapters(branch: String?) = MangaDetails(
manga = manga.filterChapters(branch),
localManga = localManga?.run {
copy(manga = manga.filterChapters(branch))
},
description = description,
isLoaded = isLoaded,
)
}

View File

@@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.details.domain.model.DoubleManga import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
@@ -20,7 +20,7 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import javax.inject.Inject import javax.inject.Inject
@Deprecated("") /* TODO: remove */
class DetailsInteractor @Inject constructor( class DetailsInteractor @Inject constructor(
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository, private val favouritesRepository: FavouritesRepository,
@@ -66,13 +66,22 @@ class DetailsInteractor @Inject constructor(
} }
} }
suspend fun updateLocal(subject: DoubleManga?, localManga: LocalManga): DoubleManga? { suspend fun updateLocal(subject: MangaDetails?, localManga: LocalManga): MangaDetails? {
return if (subject?.any?.id == localManga.manga.id) { subject ?: return null
subject.copy( return if (subject.id == localManga.manga.id) {
localManga = runCatchingCancellable { if (subject.isLocal) {
localMangaRepository.getDetails(localManga.manga) subject.copy(
}, manga = localManga.manga,
) )
} else {
subject.copy(
localManga = runCatchingCancellable {
localManga.copy(
manga = localMangaRepository.getDetails(localManga.manga),
)
}.getOrNull() ?: subject.local,
)
}
} else { } else {
subject subject
} }

View File

@@ -0,0 +1,85 @@
package org.koitharu.kotatsu.details.domain
import android.text.Html
import android.text.SpannableString
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import androidx.core.text.getSpans
import androidx.core.text.parseAsHtml
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.peek
import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.explore.domain.RecoverMangaUseCase
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.recoverNotNull
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
class DetailsLoadUseCase @Inject constructor(
private val mangaDataRepository: MangaDataRepository,
private val localMangaRepository: LocalMangaRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val recoverUseCase: RecoverMangaUseCase,
private val imageGetter: Html.ImageGetter,
) {
operator fun invoke(intent: MangaIntent): Flow<MangaDetails> = channelFlow {
val manga = requireNotNull(mangaDataRepository.resolveIntent(intent)) {
"Cannot resolve intent $intent"
}
val local = if (!manga.isLocal) {
async {
localMangaRepository.findSavedManga(manga)
}
} else {
null
}
send(MangaDetails(manga, null, null, false))
val details = getDetails(manga)
send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false), false))
send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true), true))
}
private suspend fun getDetails(seed: Manga) = runCatchingCancellable {
val repository = mangaRepositoryFactory.create(seed.source)
repository.getDetails(seed)
}.recoverNotNull { e ->
if (e is NotFoundException) {
recoverUseCase(seed)
} else {
null
}
}.getOrThrow()
private suspend fun String.parseAsHtml(withImages: Boolean): CharSequence? {
return if (withImages) {
runInterruptible(Dispatchers.IO) {
parseAsHtml(imageGetter = imageGetter)
}.filterSpans()
} else {
runInterruptible(Dispatchers.Default) {
parseAsHtml()
}.filterSpans().sanitize()
}.takeUnless { it.isBlank() }
}
private fun Spanned.filterSpans(): Spanned {
val spannable = SpannableString.valueOf(this)
val spans = spannable.getSpans<ForegroundColorSpan>()
for (span in spans) {
spannable.removeSpan(span)
}
return spannable
}
}

View File

@@ -1,74 +0,0 @@
package org.koitharu.kotatsu.details.domain
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.details.domain.model.DoubleManga
import org.koitharu.kotatsu.explore.domain.RecoverMangaUseCase
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.recoverNotNull
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
class DoubleMangaLoadUseCase @Inject constructor(
private val mangaDataRepository: MangaDataRepository,
private val localMangaRepository: LocalMangaRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val recoverUseCase: RecoverMangaUseCase,
) {
suspend operator fun invoke(manga: Manga): DoubleManga = coroutineScope {
val remoteDeferred = async(Dispatchers.Default) { loadRemote(manga) }
val localDeferred = async(Dispatchers.Default) { loadLocal(manga) }
DoubleManga(
remoteManga = remoteDeferred.await(),
localManga = localDeferred.await(),
)
}
suspend operator fun invoke(mangaId: Long): DoubleManga {
val manga = mangaDataRepository.findMangaById(mangaId) ?: throwNFE()
return invoke(manga)
}
suspend operator fun invoke(intent: MangaIntent): DoubleManga {
val manga = mangaDataRepository.resolveIntent(intent) ?: throwNFE()
return invoke(manga)
}
private suspend fun loadLocal(manga: Manga): Result<Manga>? {
return runCatchingCancellable {
if (manga.isLocal) {
localMangaRepository.getDetails(manga)
} else {
localMangaRepository.findSavedManga(manga)?.manga
} ?: return null
}
}
private suspend fun loadRemote(manga: Manga): Result<Manga>? {
return runCatchingCancellable {
val seed = if (manga.isLocal) {
localMangaRepository.getRemoteManga(manga)
} else {
manga
} ?: return null
val repository = mangaRepositoryFactory.create(seed.source)
repository.getDetails(seed)
}.recoverNotNull { e ->
if (e is NotFoundException) {
recoverUseCase(manga)
} else {
null
}
}
}
private fun throwNFE(): Nothing = throw NotFoundException("Cannot find manga", "")
}

View File

@@ -0,0 +1,55 @@
package org.koitharu.kotatsu.details.domain
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.model.findChapter
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject
class ProgressUpdateUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val database: MangaDatabase,
private val localMangaRepository: LocalMangaRepository,
) {
suspend operator fun invoke(manga: Manga): Float {
val history = database.historyDao.find(manga.id) ?: return PROGRESS_NONE
val seed = if (manga.isLocal) {
localMangaRepository.getRemoteManga(manga) ?: manga
} else {
manga
}
val repo = mangaRepositoryFactory.create(seed.source)
val details = if (manga.source != seed.source || seed.chapters.isNullOrEmpty()) {
repo.getDetails(seed)
} else {
seed
}
val chapter = details.findChapter(history.chapterId) ?: return PROGRESS_NONE
val chapters = details.getChapters(chapter.branch) ?: return PROGRESS_NONE
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
if (pagesCount == 0) {
return PROGRESS_NONE
}
val pagePercent = (history.page + 1) / pagesCount.toFloat()
val ppc = 1f / chaptersCount
val result = ppc * chapterIndex + ppc * pagePercent
if (result != history.percent) {
database.historyDao.update(
history.copy(
chapterId = chapter.id,
percent = result,
),
)
}
return result
}
}

View File

@@ -1,76 +0,0 @@
package org.koitharu.kotatsu.details.domain.model
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.data.filterChapters
data class DoubleManga(
private val remoteManga: Result<Manga>?,
private val localManga: Result<Manga>?,
) {
constructor(manga: Manga) : this(
remoteManga = if (manga.source != MangaSource.LOCAL) Result.success(manga) else null,
localManga = if (manga.source == MangaSource.LOCAL) Result.success(manga) else null,
)
val remote: Manga?
get() = remoteManga?.getOrNull()
val local: Manga?
get() = localManga?.getOrNull()
val any: Manga?
get() = remote ?: local
val hasRemote: Boolean
get() = remoteManga?.isSuccess == true
val hasLocal: Boolean
get() = localManga?.isSuccess == true
val chapters: List<MangaChapter>? by lazy(LazyThreadSafetyMode.PUBLICATION) {
mergeChapters()
}
fun requireAny(): Manga {
val result = remoteManga?.getOrNull() ?: localManga?.getOrNull()
if (result != null) {
return result
}
throw (
remoteManga?.exceptionOrNull()
?: localManga?.exceptionOrNull()
?: IllegalStateException("No online either local manga available")
)
}
fun filterChapters(branch: String?) = DoubleManga(
remoteManga?.map { it.filterChapters(branch) },
localManga?.map { it.filterChapters(branch) },
)
private fun mergeChapters(): List<MangaChapter>? {
val remoteChapters = remote?.chapters
val localChapters = local?.chapters
if (localChapters == null && remoteChapters == null) {
return null
}
val localMap = if (!localChapters.isNullOrEmpty()) {
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
} else {
null
}
val result = ArrayList<MangaChapter>(maxOf(remoteChapters?.size ?: 0, localChapters?.size ?: 0))
remoteChapters?.forEach { r ->
localMap?.remove(r.id)?.let { l ->
result.add(l)
} ?: result.add(r)
}
localMap?.values?.let {
result.addAll(it)
}
return result
}
}

View File

@@ -5,8 +5,9 @@ import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.core.cache.ContentCache import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.CoroutineIntentService import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
@@ -34,12 +35,13 @@ class MangaPrefetchService : CoroutineIntentService() {
override suspend fun processIntent(startId: Int, intent: Intent) { override suspend fun processIntent(startId: Int, intent: Intent) {
when (intent.action) { when (intent.action) {
ACTION_PREFETCH_DETAILS -> prefetchDetails( ACTION_PREFETCH_DETAILS -> prefetchDetails(
manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga ?: return, manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga
?: return,
) )
ACTION_PREFETCH_PAGES -> prefetchPages( ACTION_PREFETCH_PAGES -> prefetchPages(
chapter = intent.getParcelableExtraCompat<ParcelableMangaChapters>(EXTRA_CHAPTER) chapter = intent.getParcelableExtraCompat<ParcelableChapter>(EXTRA_CHAPTER)?.chapter
?.chapters?.singleOrNull() ?: return, ?: return,
) )
ACTION_PREFETCH_LAST -> prefetchLast() ACTION_PREFETCH_LAST -> prefetchLast()
@@ -71,7 +73,7 @@ class MangaPrefetchService : CoroutineIntentService() {
val chapter = if (history == null) { val chapter = if (history == null) {
chapters.firstOrNull() chapters.firstOrNull()
} else { } else {
chapters.find { x -> x.id == history.chapterId } ?: chapters.firstOrNull() chapters.findById(history.chapterId) ?: chapters.firstOrNull()
} ?: return } ?: return
runCatchingCancellable { repo.getPages(chapter) } runCatchingCancellable { repo.getPages(chapter) }
} }
@@ -88,7 +90,7 @@ class MangaPrefetchService : CoroutineIntentService() {
if (!isPrefetchAvailable(context, manga.source)) return if (!isPrefetchAvailable(context, manga.source)) return
val intent = Intent(context, MangaPrefetchService::class.java) val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_DETAILS intent.action = ACTION_PREFETCH_DETAILS
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false)) intent.putExtra(EXTRA_MANGA, ParcelableManga(manga))
context.startService(intent) context.startService(intent)
} }
@@ -96,7 +98,7 @@ class MangaPrefetchService : CoroutineIntentService() {
if (!isPrefetchAvailable(context, chapter.source)) return if (!isPrefetchAvailable(context, chapter.source)) return
val intent = Intent(context, MangaPrefetchService::class.java) val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_PAGES intent.action = ACTION_PREFETCH_PAGES
intent.putExtra(EXTRA_CHAPTER, ParcelableMangaChapters(listOf(chapter))) intent.putExtra(EXTRA_CHAPTER, ParcelableChapter(chapter))
try { try {
context.startService(intent) context.startService(intent)
} catch (e: IllegalStateException) { } catch (e: IllegalStateException) {
@@ -119,7 +121,10 @@ class MangaPrefetchService : CoroutineIntentService() {
if (context.isPowerSaveMode()) { if (context.isPowerSaveMode()) {
return false return false
} }
val entryPoint = EntryPointAccessors.fromApplication(context, PrefetchCompanionEntryPoint::class.java) val entryPoint = EntryPointAccessors.fromApplication(
context,
PrefetchCompanionEntryPoint::class.java,
)
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled
} }
} }

View File

@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.details.ui package org.koitharu.kotatsu.details.ui
import android.view.InputDevice
import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.View.OnLayoutChangeListener import android.view.View.OnLayoutChangeListener
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
@@ -12,7 +14,7 @@ class ChaptersBottomSheetMediator(
private val behavior: BottomSheetBehavior<*>, private val behavior: BottomSheetBehavior<*>,
) : OnBackPressedCallback(false), ) : OnBackPressedCallback(false),
ActionModeListener, ActionModeListener,
OnLayoutChangeListener { OnLayoutChangeListener, View.OnGenericMotionListener {
private var lockCounter = 0 private var lockCounter = 0
@@ -55,6 +57,20 @@ class ChaptersBottomSheetMediator(
} }
} }
override fun onGenericMotion(v: View?, event: MotionEvent): Boolean {
if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
if (event.actionMasked == MotionEvent.ACTION_SCROLL) {
if (event.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0f) {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
} else {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
return true
}
}
return false
}
fun lock() { fun lock() {
lockCounter++ lockCounter++
behavior.isDraggable = lockCounter <= 0 behavior.isDraggable = lockCounter <= 0

View File

@@ -2,33 +2,30 @@ package org.koitharu.kotatsu.details.ui
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
fun mapChapters( fun MangaDetails.mapChapters(
remoteManga: Manga?,
localManga: Manga?,
history: MangaHistory?, history: MangaHistory?,
newCount: Int, newCount: Int,
branch: String?, branch: String?,
bookmarks: List<Bookmark>, bookmarks: List<Bookmark>,
): List<ChapterListItem> { ): List<ChapterListItem> {
val remoteChapters = remoteManga?.getChapters(branch).orEmpty() val remoteChapters = chapters[branch].orEmpty()
val localChapters = localManga?.getChapters(branch).orEmpty() val localChapters = local?.manga?.getChapters(branch).orEmpty()
if (remoteChapters.isEmpty() && localChapters.isEmpty()) { if (remoteChapters.isEmpty() && localChapters.isEmpty()) {
return emptyList() return emptyList()
} }
val bookmarked = bookmarks.mapToSet { it.chapterId } val bookmarked = bookmarks.mapToSet { it.chapterId }
val currentId = history?.chapterId ?: 0L val currentId = history?.chapterId ?: 0L
val newFrom = if (newCount == 0 || remoteChapters.isEmpty()) Int.MAX_VALUE else remoteChapters.size - newCount val newFrom = if (newCount == 0 || remoteChapters.isEmpty()) Int.MAX_VALUE else remoteChapters.size - newCount
val chaptersSize = maxOf(remoteChapters.size, localChapters.size) val ids = buildSet(maxOf(remoteChapters.size, localChapters.size)) {
val ids = buildSet(chaptersSize) {
remoteChapters.mapTo(this) { it.id } remoteChapters.mapTo(this) { it.id }
localChapters.mapTo(this) { it.id } localChapters.mapTo(this) { it.id }
} }
val result = ArrayList<ChapterListItem>(chaptersSize) val result = ArrayList<ChapterListItem>(ids.size)
val localMap = if (localChapters.isNotEmpty()) { val localMap = if (localChapters.isNotEmpty()) {
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id } localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
} else { } else {
@@ -40,7 +37,7 @@ fun mapChapters(
if (chapter.id == currentId) { if (chapter.id == currentId) {
isUnread = true isUnread = true
} }
result += chapter.toListItem( result += (local ?: chapter).toListItem(
isCurrent = chapter.id == currentId, isCurrent = chapter.id == currentId,
isUnread = isUnread, isUnread = isUnread,
isNew = isUnread && result.size >= newFrom, isNew = isUnread && result.size >= newFrom,
@@ -57,7 +54,7 @@ fun mapChapters(
isCurrent = chapter.id == currentId, isCurrent = chapter.id == currentId,
isUnread = isUnread, isUnread = isUnread,
isNew = false, isNew = false,
isDownloaded = remoteManga != null, isDownloaded = !isLocal,
isBookmarked = chapter.id in bookmarked, isBookmarked = chapter.id in bookmarked,
) )
} }

View File

@@ -39,7 +39,6 @@ import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.util.ViewBadge
import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
@@ -49,6 +48,7 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.core.util.ext.setNavigationIconSafe import org.koitharu.kotatsu.core.util.ext.setNavigationIconSafe
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.service.MangaPrefetchService
@@ -74,7 +74,6 @@ class DetailsActivity :
@Inject @Inject
lateinit var appShortcutManager: AppShortcutManager lateinit var appShortcutManager: AppShortcutManager
private lateinit var viewBadge: ViewBadge
private var buttonTip: WeakReference<ButtonTip>? = null private var buttonTip: WeakReference<ButtonTip>? = null
private val viewModel: DetailsViewModel by viewModels() private val viewModel: DetailsViewModel by viewModels()
@@ -89,8 +88,8 @@ class DetailsActivity :
} }
viewBinding.buttonRead.setOnClickListener(this) viewBinding.buttonRead.setOnClickListener(this)
viewBinding.buttonRead.setOnLongClickListener(this) viewBinding.buttonRead.setOnLongClickListener(this)
viewBinding.buttonRead.setOnContextClickListenerCompat(this)
viewBinding.buttonDropdown.setOnClickListener(this) viewBinding.buttonDropdown.setOnClickListener(this)
viewBadge = ViewBadge(viewBinding.buttonRead, this)
if (viewBinding.layoutBottom != null) { if (viewBinding.layoutBottom != null) {
val behavior = BottomSheetBehavior.from(checkNotNull(viewBinding.layoutBottom)) val behavior = BottomSheetBehavior.from(checkNotNull(viewBinding.layoutBottom))
@@ -103,6 +102,7 @@ class DetailsActivity :
viewBinding.toolbarChapters?.setNavigationOnClickListener { viewBinding.toolbarChapters?.setNavigationOnClickListener {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED behavior.state = BottomSheetBehavior.STATE_COLLAPSED
} }
viewBinding.toolbarChapters?.setOnGenericMotionListener(bsMediator)
} else { } else {
chaptersMenuProvider = ChaptersMenuProvider(viewModel, null) chaptersMenuProvider = ChaptersMenuProvider(viewModel, null)
addMenuProvider(chaptersMenuProvider) addMenuProvider(chaptersMenuProvider)
@@ -110,7 +110,6 @@ class DetailsActivity :
onBackPressedDispatcher.addCallback(chaptersMenuProvider) onBackPressedDispatcher.addCallback(chaptersMenuProvider)
viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated) viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated)
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved) viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
viewModel.onError.observeEvent( viewModel.onError.observeEvent(
this, this,
@@ -134,13 +133,19 @@ class DetailsActivity :
viewBinding.toolbarChapters?.subtitle = it viewBinding.toolbarChapters?.subtitle = it
viewBinding.textViewSubtitle?.textAndVisible = it viewBinding.textViewSubtitle?.textAndVisible = it
} }
viewModel.isChaptersReversed.observe(this, MenuInvalidator(viewBinding.toolbarChapters ?: this)) viewModel.isChaptersReversed.observe(
this,
MenuInvalidator(viewBinding.toolbarChapters ?: this),
)
viewModel.favouriteCategories.observe(this, MenuInvalidator(this)) viewModel.favouriteCategories.observe(this, MenuInvalidator(this))
viewModel.branches.observe(this) { viewModel.branches.observe(this) {
viewBinding.buttonDropdown.isVisible = it.size > 1 viewBinding.buttonDropdown.isVisible = it.size > 1
} }
viewModel.chapters.observe(this, PrefetchObserver(this)) viewModel.chapters.observe(this, PrefetchObserver(this))
viewModel.onDownloadStarted.observeEvent(this, DownloadStartedObserver(viewBinding.containerDetails)) viewModel.onDownloadStarted.observeEvent(
this,
DownloadStartedObserver(viewBinding.containerDetails),
)
addMenuProvider( addMenuProvider(
DetailsMenuProvider( DetailsMenuProvider(
@@ -243,7 +248,11 @@ class DetailsActivity :
right = insets.right, right = insets.right,
) )
if (insets.bottom > 0) { if (insets.bottom > 0) {
window.setNavigationBarTransparentCompat(this, viewBinding.layoutBottom?.elevation ?: 0f, 0.9f) window.setNavigationBarTransparentCompat(
this,
viewBinding.layoutBottom?.elevation ?: 0f,
0.9f,
)
} }
viewBinding.cardChapters?.updateLayoutParams<MarginLayoutParams> { viewBinding.cardChapters?.updateLayoutParams<MarginLayoutParams> {
bottomMargin = insets.bottom + marginEnd bottomMargin = insets.bottom + marginEnd
@@ -265,18 +274,23 @@ class DetailsActivity :
} }
val text = when { val text = when {
!info.isValid -> getString(R.string.loading_) !info.isValid -> getString(R.string.loading_)
info.currentChapter >= 0 -> getString(R.string.chapter_d_of_d, info.currentChapter + 1, info.totalChapters) info.currentChapter >= 0 -> getString(
R.string.chapter_d_of_d,
info.currentChapter + 1,
info.totalChapters,
)
info.totalChapters == 0 -> getString(R.string.no_chapters) info.totalChapters == 0 -> getString(R.string.no_chapters)
else -> resources.getQuantityString(R.plurals.chapters, info.totalChapters, info.totalChapters) else -> resources.getQuantityString(
R.plurals.chapters,
info.totalChapters,
info.totalChapters,
)
} }
viewBinding.toolbarChapters?.title = text viewBinding.toolbarChapters?.title = text
viewBinding.textViewTitle?.text = text viewBinding.textViewTitle?.text = text
} }
private fun onNewChaptersChanged(newChapters: Int) {
viewBadge.counter = newChapters
}
private fun showBranchPopupMenu(v: View) { private fun showBranchPopupMenu(v: View) {
val menu = PopupMenu(v.context, v) val menu = PopupMenu(v.context, v)
val branches = viewModel.branches.value val branches = viewModel.branches.value
@@ -286,7 +300,12 @@ class DetailsActivity :
append(' ') append(' ')
append(' ') append(' ')
inSpans( inSpans(
ForegroundColorSpan(v.context.getThemeColor(android.R.attr.textColorSecondary, Color.LTGRAY)), ForegroundColorSpan(
v.context.getThemeColor(
android.R.attr.textColorSecondary,
Color.LTGRAY,
),
),
RelativeSizeSpan(0.74f), RelativeSizeSpan(0.74f),
) { ) {
append(branch.count.toString()) append(branch.count.toString())
@@ -305,7 +324,8 @@ class DetailsActivity :
val manga = viewModel.manga.value ?: return val manga = viewModel.manga.value ?: return
val chapterId = viewModel.historyInfo.value.history?.chapterId val chapterId = viewModel.historyInfo.value.history?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) { if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
val snackbar = makeSnackbar(getString(R.string.chapter_is_missing), Snackbar.LENGTH_SHORT) val snackbar =
makeSnackbar(getString(R.string.chapter_is_missing), Snackbar.LENGTH_SHORT)
snackbar.show() snackbar.show()
} else { } else {
startActivity( startActivity(
@@ -331,7 +351,10 @@ class DetailsActivity :
view.isVisible = isVisible view.isVisible = isVisible
} }
private fun makeSnackbar(text: CharSequence, @BaseTransientBottomBar.Duration duration: Int): Snackbar { private fun makeSnackbar(
text: CharSequence,
@BaseTransientBottomBar.Duration duration: Int,
): Snackbar {
val sb = Snackbar.make(viewBinding.containerDetails, text, duration) val sb = Snackbar.make(viewBinding.containerDetails, text, duration)
if (viewBinding.layoutBottom?.isVisible == true) { if (viewBinding.layoutBottom?.isVisible == true) {
sb.anchorView = viewBinding.toolbarChapters sb.anchorView = viewBinding.toolbarChapters
@@ -369,7 +392,7 @@ class DetailsActivity :
fun newIntent(context: Context, manga: Manga): Intent { fun newIntent(context: Context, manga: Manga): Intent {
return Intent(context, DetailsActivity::class.java) return Intent(context, DetailsActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true)) .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
} }
fun newIntent(context: Context, mangaId: Long): Intent { fun newIntent(context: Context, mangaId: Long): Intent {

View File

@@ -11,6 +11,8 @@ import android.widget.Toast
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.text.buildSpannedString
import androidx.core.text.color
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
@@ -21,6 +23,7 @@ import coil.request.SuccessResult
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
@@ -37,12 +40,14 @@ import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.crossfade import org.koitharu.kotatsu.core.util.ext.crossfade
import org.koitharu.kotatsu.core.util.ext.drawableTop import org.koitharu.kotatsu.core.util.ext.drawableTop
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.isTextTruncated import org.koitharu.kotatsu.core.util.ext.isTextTruncated
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.parentView import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
@@ -69,6 +74,7 @@ import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorShee
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.search.ui.SearchActivity
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint @AndroidEntryPoint
class DetailsFragment : class DetailsFragment :
@@ -112,9 +118,9 @@ class DetailsFragment :
viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged) viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)
viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged) viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged) viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged)
viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged) viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged)
viewModel.relatedManga.observe(viewLifecycleOwner, ::onRelatedMangaChanged) viewModel.relatedManga.observe(viewLifecycleOwner, ::onRelatedMangaChanged)
combine(viewModel.chapters, viewModel.newChaptersCount, ::Pair).observe(viewLifecycleOwner, ::onChaptersChanged)
} }
override fun onItemClick(item: Bookmark, view: View) { override fun onItemClick(item: Bookmark, view: View) {
@@ -159,21 +165,22 @@ class DetailsFragment :
} }
when (manga.state) { when (manga.state) {
MangaState.FINISHED -> { MangaState.FINISHED -> infoLayout.textViewState.apply {
infoLayout.textViewState.apply { textAndVisible = resources.getString(R.string.state_finished)
textAndVisible = resources.getString(R.string.state_finished) drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_finished)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_finished)
}
} }
MangaState.ONGOING -> { MangaState.ONGOING -> infoLayout.textViewState.apply {
infoLayout.textViewState.apply { textAndVisible = resources.getString(R.string.state_ongoing)
textAndVisible = resources.getString(R.string.state_ongoing) drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_ongoing)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_ongoing)
}
} }
else -> infoLayout.textViewState.isVisible = false MangaState.ABANDONED -> infoLayout.textViewState.apply {
textAndVisible = resources.getString(R.string.state_abandoned)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_abandoned)
}
null -> infoLayout.textViewState.isVisible = false
} }
if (manga.source == MangaSource.LOCAL) { if (manga.source == MangaSource.LOCAL) {
infoLayout.textViewSource.isVisible = false infoLayout.textViewSource.isVisible = false
@@ -189,14 +196,28 @@ class DetailsFragment :
} }
} }
private fun onChaptersChanged(chapters: List<ChapterListItem>?) { private fun onChaptersChanged(data: Pair<List<ChapterListItem>?, Int>) {
val (chapters, newChapters) = data
val infoLayout = requireViewBinding().infoLayout val infoLayout = requireViewBinding().infoLayout
if (chapters.isNullOrEmpty()) { if (chapters.isNullOrEmpty()) {
infoLayout.textViewChapters.isVisible = false infoLayout.textViewChapters.isVisible = false
} else { } else {
val count = chapters.countChaptersByBranch() val count = chapters.countChaptersByBranch()
infoLayout.textViewChapters.isVisible = true infoLayout.textViewChapters.isVisible = true
infoLayout.textViewChapters.text = resources.getQuantityString(R.plurals.chapters, count, count) val chaptersText = resources.getQuantityString(R.plurals.chapters, count, count)
infoLayout.textViewChapters.text = if (newChapters == 0) {
chaptersText
} else {
buildSpannedString {
append(chaptersText)
append(' ')
color(infoLayout.textViewChapters.context.getThemeColor(materialR.attr.colorError)) {
append("(+")
append(newChapters.toString())
append(')')
}
}
}
} }
} }
@@ -247,11 +268,7 @@ class DetailsFragment :
} }
private fun onLoadingStateChanged(isLoading: Boolean) { private fun onLoadingStateChanged(isLoading: Boolean) {
if (isLoading) { requireViewBinding().progressBar.showOrHide(isLoading)
requireViewBinding().progressBar.show()
} else {
requireViewBinding().progressBar.hide()
}
} }
private fun onBookmarksChanged(bookmarks: List<Bookmark>) { private fun onBookmarksChanged(bookmarks: List<Bookmark>) {

View File

@@ -1,12 +1,5 @@
package org.koitharu.kotatsu.details.ui package org.koitharu.kotatsu.details.ui
import android.text.Html
import android.text.SpannableString
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import androidx.core.net.toUri
import androidx.core.text.getSpans
import androidx.core.text.parseAsHtml
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -17,22 +10,21 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.getPreferredBranch import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
@@ -40,16 +32,15 @@ import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.combine
import org.koitharu.kotatsu.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.onFirst
import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.details.domain.BranchComparator import org.koitharu.kotatsu.details.domain.BranchComparator
import org.koitharu.kotatsu.details.domain.DetailsInteractor import org.koitharu.kotatsu.details.domain.DetailsInteractor
import org.koitharu.kotatsu.details.domain.DoubleMangaLoadUseCase import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
import org.koitharu.kotatsu.details.domain.RelatedMangaUseCase import org.koitharu.kotatsu.details.domain.RelatedMangaUseCase
import org.koitharu.kotatsu.details.domain.model.DoubleManga
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.model.MangaBranch import org.koitharu.kotatsu.details.ui.model.MangaBranch
@@ -73,21 +64,19 @@ class DetailsViewModel @Inject constructor(
private val bookmarksRepository: BookmarksRepository, private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>, private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
private val imageGetter: Html.ImageGetter,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>, @LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
private val downloadScheduler: DownloadWorker.Scheduler, private val downloadScheduler: DownloadWorker.Scheduler,
private val interactor: DetailsInteractor, private val interactor: DetailsInteractor,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
private val doubleMangaLoadUseCase: DoubleMangaLoadUseCase,
private val relatedMangaUseCase: RelatedMangaUseCase, private val relatedMangaUseCase: RelatedMangaUseCase,
private val extraProvider: ListExtraProvider, private val extraProvider: ListExtraProvider,
networkState: NetworkState, private val detailsLoadUseCase: DetailsLoadUseCase,
private val progressUpdateUseCase: ProgressUpdateUseCase,
) : BaseViewModel() { ) : BaseViewModel() {
private val intent = MangaIntent(savedStateHandle) private val intent = MangaIntent(savedStateHandle)
private val mangaId = intent.mangaId private val mangaId = intent.mangaId
private val doubleManga: MutableStateFlow<DoubleManga?> = MutableStateFlow(intent.manga?.let { DoubleManga(it) })
private var loadingJob: Job private var loadingJob: Job
val onShowToast = MutableEventFlow<Int>() val onShowToast = MutableEventFlow<Int>()
@@ -95,8 +84,9 @@ class DetailsViewModel @Inject constructor(
val onSelectChapter = MutableEventFlow<Long>() val onSelectChapter = MutableEventFlow<Long>()
val onDownloadStarted = MutableEventFlow<Unit>() val onDownloadStarted = MutableEventFlow<Unit>()
val manga = doubleManga.map { it?.any } val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) })
.stateIn(viewModelScope, SharingStarted.Eagerly, doubleManga.value?.any) val manga = details.map { x -> x?.toManga() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val history = historyRepository.observeOne(mangaId) val history = historyRepository.observeOne(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
@@ -104,8 +94,13 @@ class DetailsViewModel @Inject constructor(
val favouriteCategories = interactor.observeIsFavourite(mangaId) val favouriteCategories = interactor.observeIsFavourite(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
val newChaptersCount = interactor.observeNewChapters(mangaId) val newChaptersCount = details.flatMapLatest { d ->
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) if (d?.isLocal == false) {
interactor.observeNewChapters(mangaId)
} else {
flowOf(0)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
private val chaptersQuery = MutableStateFlow("") private val chaptersQuery = MutableStateFlow("")
val selectedBranch = MutableStateFlow<String?>(null) val selectedBranch = MutableStateFlow<String?>(null)
@@ -133,28 +128,17 @@ class DetailsViewModel @Inject constructor(
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList()) if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList()) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
val localSize = doubleManga val localSize = details
.map { .map { it?.local }
val local = it?.local .distinctUntilChanged()
if (local != null) { .map { local ->
val file = local.url.toUri().toFileOrNull() local?.file?.computeSize() ?: 0L
file?.computeSize() ?: 0L
} else {
0L
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(), 0) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(), 0)
val description = manga @Deprecated("")
.distinctUntilChangedBy { it?.description.orEmpty() } val description = details
.transformLatest { .map { it?.description }
val description = it?.description .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, null)
if (description.isNullOrEmpty()) {
emit(null)
} else {
emit(description.parseAsHtml().filterSpans().sanitize())
emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans())
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), null)
val onMangaRemoved = MutableEventFlow<Manga>() val onMangaRemoved = MutableEventFlow<Manga>()
val isScrobblingAvailable: Boolean val isScrobblingAvailable: Boolean
@@ -163,9 +147,7 @@ class DetailsViewModel @Inject constructor(
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId) val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val relatedManga: StateFlow<List<MangaItemModel>> = doubleManga.map { val relatedManga: StateFlow<List<MangaItemModel>> = manga
it?.remote
}.distinctUntilChangedBy { it?.id }
.mapLatest { .mapLatest {
if (it != null && settings.isRelatedMangaEnabled) { if (it != null && settings.isRelatedMangaEnabled) {
relatedMangaUseCase.invoke(it)?.toUi(ListMode.GRID, extraProvider).orEmpty() relatedMangaUseCase.invoke(it)?.toUi(ListMode.GRID, extraProvider).orEmpty()
@@ -176,33 +158,32 @@ class DetailsViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) .stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
val branches: StateFlow<List<MangaBranch>> = combine( val branches: StateFlow<List<MangaBranch>> = combine(
doubleManga, details,
selectedBranch, selectedBranch,
) { m, b -> ) { m, b ->
val chapters = m?.chapters (m?.chapters ?: return@combine emptyList())
if (chapters.isNullOrEmpty()) return@combine emptyList()
chapters.groupBy { x -> x.branch }
.map { x -> MangaBranch(x.key, x.value.size, x.key == b) } .map { x -> MangaBranch(x.key, x.value.size, x.key == b) }
.sortedWith(BranchComparator()) .sortedWith(BranchComparator())
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val isChaptersEmpty: StateFlow<Boolean> = combine( val isChaptersEmpty: StateFlow<Boolean> = details.map {
doubleManga, it != null && it.isLoaded && it.allChapters.isEmpty()
isLoading,
) { manga, loading ->
manga?.any != null && manga.chapters.isNullOrEmpty() && !loading
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
val chapters = combine( val chapters = combine(
combine( combine(
doubleManga, details,
history, history,
selectedBranch, selectedBranch,
newChaptersCount, newChaptersCount,
bookmarks, bookmarks,
networkState, ) { manga, history, branch, news, bookmarks ->
) { manga, history, branch, news, bookmarks, isOnline -> manga?.mapChapters(
mapChapters(manga?.remote?.takeIf { isOnline }, manga?.local, history, news, branch, bookmarks) history,
news,
branch,
bookmarks,
).orEmpty()
}, },
isChaptersReversed, isChaptersReversed,
chaptersQuery, chaptersQuery,
@@ -225,6 +206,13 @@ class DetailsViewModel @Inject constructor(
onShowTip.call(Unit) onShowTip.call(Unit)
} }
} }
launchJob(Dispatchers.Default) {
val manga = details.firstOrNull { !it?.chapters.isNullOrEmpty() } ?: return@launchJob
val h = history.firstOrNull()
if (h != null) {
progressUpdateUseCase(manga.toManga())
}
}
} }
fun reload() { fun reload() {
@@ -233,7 +221,7 @@ class DetailsViewModel @Inject constructor(
} }
fun deleteLocal() { fun deleteLocal() {
val m = doubleManga.value?.local val m = details.value?.local?.manga
if (m == null) { if (m == null) {
onShowToast.call(R.string.file_not_found) onShowToast.call(R.string.file_not_found)
return return
@@ -286,13 +274,13 @@ class DetailsViewModel @Inject constructor(
fun markChapterAsCurrent(chapterId: Long) { fun markChapterAsCurrent(chapterId: Long) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val manga = checkNotNull(doubleManga.value) val manga = checkNotNull(details.value)
val chapters = checkNotNull(manga.filterChapters(selectedBranchValue).chapters) val chapters = checkNotNull(manga.chapters[selectedBranchValue])
val chapterIndex = chapters.indexOfFirst { it.id == chapterId } val chapterIndex = chapters.indexOfFirst { it.id == chapterId }
check(chapterIndex in chapters.indices) { "Chapter not found" } check(chapterIndex in chapters.indices) { "Chapter not found" }
val percent = chapterIndex / chapters.size.toFloat() val percent = chapterIndex / chapters.size.toFloat()
historyRepository.addOrUpdate( historyRepository.addOrUpdate(
manga = manga.requireAny(), manga = manga.toManga(),
chapterId = chapterId, chapterId = chapterId,
page = 0, page = 0,
scroll = 0, scroll = 0,
@@ -304,7 +292,7 @@ class DetailsViewModel @Inject constructor(
fun download(chaptersIds: Set<Long>?) { fun download(chaptersIds: Set<Long>?) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
downloadScheduler.schedule( downloadScheduler.schedule(
doubleManga.requireValue().requireAny(), details.requireValue().toManga(),
chaptersIds, chaptersIds,
) )
onDownloadStarted.call(Unit) onDownloadStarted.call(Unit)
@@ -324,12 +312,15 @@ class DetailsViewModel @Inject constructor(
} }
private fun doLoad() = launchLoadingJob(Dispatchers.Default) { private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
val result = doubleMangaLoadUseCase(intent) detailsLoadUseCase.invoke(intent)
val manga = result.requireAny() .onFirst {
// find default branch val manga = it.toManga()
val hist = historyRepository.getOne(manga) // find default branch
selectedBranch.value = manga.getPreferredBranch(hist) val hist = historyRepository.getOne(manga)
doubleManga.value = result selectedBranch.value = manga.getPreferredBranch(hist)
}.collect {
details.value = it
}
} }
private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> { private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> {
@@ -344,21 +335,12 @@ class DetailsViewModel @Inject constructor(
private suspend fun onDownloadComplete(downloadedManga: LocalManga?) { private suspend fun onDownloadComplete(downloadedManga: LocalManga?) {
downloadedManga ?: return downloadedManga ?: return
launchJob { launchJob {
doubleManga.update { details.update {
interactor.updateLocal(it, downloadedManga) interactor.updateLocal(it, downloadedManga)
} }
} }
} }
private fun Spanned.filterSpans(): CharSequence {
val spannable = SpannableString.valueOf(this)
val spans = spannable.getSpans<ForegroundColorSpan>()
for (span in spans) {
spannable.removeSpan(span)
}
return spannable.trim()
}
private fun getScrobbler(index: Int): Scrobbler? { private fun getScrobbler(index: Int): Scrobbler? {
val info = scrobblingInfo.value.getOrNull(index) val info = scrobblingInfo.value.getOrNull(index)
val scrobbler = if (info != null) { val scrobbler = if (info != null) {

View File

@@ -6,6 +6,7 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.drawableEnd
import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.drawableStart
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
@@ -47,7 +48,6 @@ fun chapterListItemAD(
} }
binding.imageViewBookmarked.isVisible = item.isBookmarked binding.imageViewBookmarked.isVisible = item.isBookmarked
binding.imageViewDownloaded.isVisible = item.isDownloaded binding.imageViewDownloaded.isVisible = item.isDownloaded
// binding.imageViewNew.isVisible = item.isNew
binding.textViewTitle.drawableStart = if (item.isNew) { binding.textViewTitle.drawableStart = if (item.isNew) {
ContextCompat.getDrawable(context, R.drawable.ic_new) ContextCompat.getDrawable(context, R.drawable.ic_new)
} else { } else {

View File

@@ -45,6 +45,6 @@ class RelatedMangaActivity : BaseActivity<ActivityContainerBinding>(), AppBarOwn
companion object { companion object {
fun newIntent(context: Context, seed: Manga) = Intent(context, RelatedMangaActivity::class.java) fun newIntent(context: Context, seed: Manga) = Intent(context, RelatedMangaActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(seed, withChapters = false)) .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(seed))
} }
} }

View File

@@ -3,20 +3,22 @@ package org.koitharu.kotatsu.explore.data
import androidx.room.withTransaction import androidx.room.withTransaction
import dagger.Reusable import dagger.Reusable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.move
import java.util.Collections import java.util.Collections
import java.util.EnumSet import java.util.EnumSet
import javax.inject.Inject import javax.inject.Inject
@@ -76,14 +78,6 @@ class MangaSourcesRepository @Inject constructor(
} }
} }
suspend fun setSourcesEnabled(sources: Iterable<MangaSource>, isEnabled: Boolean) {
db.withTransaction {
for (s in sources) {
dao.setEnabled(s.name, isEnabled)
}
}
}
suspend fun disableAllSources() { suspend fun disableAllSources() {
db.withTransaction { db.withTransaction {
assimilateNewSources() assimilateNewSources()
@@ -99,46 +93,26 @@ class MangaSourcesRepository @Inject constructor(
} }
} }
suspend fun setPosition(source: MangaSource, index: Int) { fun observeNewSources(): Flow<Set<MangaSource>> = observeIsNewSourcesEnabled().flatMapLatest {
db.withTransaction { if (it) {
val all = dao.findAll().toMutableList() combine(
val sourceIndex = all.indexOfFirst { x -> x.source == source.name } dao.observeAll(),
if (sourceIndex !in all.indices) { observeIsNsfwDisabled(),
val entity = MangaSourceEntity( ) { entities, skipNsfw ->
source = source.name, val result = EnumSet.copyOf(remoteSources)
isEnabled = false, for (e in entities) {
sortKey = index, result.remove(MangaSource(e.source))
)
all.add(index, entity)
dao.upsert(entity)
} else {
all.move(sourceIndex, index)
}
for ((i, e) in all.withIndex()) {
if (e.sortKey != i) {
dao.setSortKey(e.source, i)
} }
} if (skipNsfw) {
result.removeAll { x -> x.isNsfw() }
}
result
}.distinctUntilChanged()
} else {
flowOf(emptySet())
} }
} }
fun observeNewSources(): Flow<Set<MangaSource>> = dao.observeAll().map { entities ->
val result = EnumSet.copyOf(remoteSources)
for (e in entities) {
result.remove(MangaSource(e.source))
}
result
}.distinctUntilChanged()
suspend fun getNewSources(): Set<MangaSource> {
val entities = dao.findAll()
val result = EnumSet.copyOf(remoteSources)
for (e in entities) {
result.remove(MangaSource(e.source))
}
return result
}
suspend fun assimilateNewSources(): Set<MangaSource> { suspend fun assimilateNewSources(): Set<MangaSource> {
val new = getNewSources() val new = getNewSources()
if (new.isEmpty()) { if (new.isEmpty()) {
@@ -153,6 +127,9 @@ class MangaSourcesRepository @Inject constructor(
) )
} }
dao.insertIfAbsent(entities) dao.insertIfAbsent(entities)
if (settings.isNsfwContentDisabled) {
new.removeAll { x -> x.isNsfw() }
}
return new return new
} }
@@ -160,6 +137,15 @@ class MangaSourcesRepository @Inject constructor(
return dao.findAll().isEmpty() return dao.findAll().isEmpty()
} }
private suspend fun getNewSources(): MutableSet<MangaSource> {
val entities = dao.findAll()
val result = EnumSet.copyOf(remoteSources)
for (e in entities) {
result.remove(MangaSource(e.source))
}
return result
}
private fun List<MangaSourceEntity>.toSources(skipNsfwSources: Boolean): List<MangaSource> { private fun List<MangaSourceEntity>.toSources(skipNsfwSources: Boolean): List<MangaSource> {
val result = ArrayList<MangaSource>(size) val result = ArrayList<MangaSource>(size)
for (entity in this) { for (entity in this) {
@@ -177,4 +163,8 @@ class MangaSourcesRepository @Inject constructor(
private fun observeIsNsfwDisabled() = settings.observeAsFlow(AppSettings.KEY_DISABLE_NSFW) { private fun observeIsNsfwDisabled() = settings.observeAsFlow(AppSettings.KEY_DISABLE_NSFW) {
isNsfwContentDisabled isNsfwContentDisabled
} }
private fun observeIsNewSourcesEnabled() = settings.observeAsFlow(AppSettings.KEY_SOURCES_NEW) {
isNewSourcesTipEnabled
}
} }

View File

@@ -7,6 +7,7 @@ import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
@@ -15,9 +16,11 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
@@ -28,6 +31,7 @@ import org.koitharu.kotatsu.core.ui.widgets.TipView
import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.databinding.FragmentExploreBinding import org.koitharu.kotatsu.databinding.FragmentExploreBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
@@ -55,6 +59,9 @@ class ExploreFragment :
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
@Inject
lateinit var shortcutManager: AppShortcutManager
private val viewModel by viewModels<ExploreViewModel>() private val viewModel by viewModels<ExploreViewModel>()
private var exploreAdapter: ExploreAdapter? = null private var exploreAdapter: ExploreAdapter? = null
@@ -141,6 +148,8 @@ class ExploreFragment :
override fun onItemLongClick(item: MangaSourceItem, view: View): Boolean { override fun onItemLongClick(item: MangaSourceItem, view: View): Boolean {
val menu = PopupMenu(view.context, view) val menu = PopupMenu(view.context, view)
menu.inflate(R.menu.popup_source) menu.inflate(R.menu.popup_source)
menu.menu.findItem(R.id.action_shortcut)
?.isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(view.context)
menu.setOnMenuItemClickListener(SourceMenuListener(item)) menu.setOnMenuItemClickListener(SourceMenuListener(item))
menu.show() menu.show()
return true return true
@@ -195,6 +204,12 @@ class ExploreFragment :
viewModel.hideSource(sourceItem.source) viewModel.hideSource(sourceItem.source)
} }
R.id.action_shortcut -> {
viewLifecycleScope.launch {
shortcutManager.requestPinShortcut(sourceItem.source)
}
}
else -> return false else -> return false
} }
return true return true

View File

@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding
@@ -44,7 +45,12 @@ fun exploreButtonsAD(
if (item.isRandomLoading) { if (item.isRandomLoading) {
val icon = CircularProgressDrawable(context) val icon = CircularProgressDrawable(context)
icon.strokeWidth = context.resources.resolveDp(2f) icon.strokeWidth = context.resources.resolveDp(2f)
icon.setColorSchemeColors(context.getThemeColor(materialR.attr.colorPrimary, Color.DKGRAY)) icon.setColorSchemeColors(
context.getThemeColor(
materialR.attr.colorPrimary,
Color.DKGRAY
)
)
binding.buttonRandom.icon = icon binding.buttonRandom.icon = icon
icon.start() icon.start()
} else { } else {
@@ -88,7 +94,13 @@ fun exploreSourceListItemAD(
listener: OnListItemClickListener<MangaSourceItem>, listener: OnListItemClickListener<MangaSourceItem>,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<MangaSourceItem, ListModel, ItemExploreSourceListBinding>( ) = adapterDelegateViewBinding<MangaSourceItem, ListModel, ItemExploreSourceListBinding>(
{ layoutInflater, parent -> ItemExploreSourceListBinding.inflate(layoutInflater, parent, false) }, { layoutInflater, parent ->
ItemExploreSourceListBinding.inflate(
layoutInflater,
parent,
false
)
},
on = { item, _, _ -> item is MangaSourceItem && !item.isGrid }, on = { item, _, _ -> item is MangaSourceItem && !item.isGrid },
) { ) {
@@ -96,6 +108,7 @@ fun exploreSourceListItemAD(
binding.root.setOnClickListener(eventListener) binding.root.setOnClickListener(eventListener)
binding.root.setOnLongClickListener(eventListener) binding.root.setOnLongClickListener(eventListener)
binding.root.setOnContextClickListenerCompat(eventListener)
bind { bind {
binding.textViewTitle.text = item.source.title binding.textViewTitle.text = item.source.title
@@ -115,7 +128,13 @@ fun exploreSourceGridItemAD(
listener: OnListItemClickListener<MangaSourceItem>, listener: OnListItemClickListener<MangaSourceItem>,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<MangaSourceItem, ListModel, ItemExploreSourceGridBinding>( ) = adapterDelegateViewBinding<MangaSourceItem, ListModel, ItemExploreSourceGridBinding>(
{ layoutInflater, parent -> ItemExploreSourceGridBinding.inflate(layoutInflater, parent, false) }, { layoutInflater, parent ->
ItemExploreSourceGridBinding.inflate(
layoutInflater,
parent,
false
)
},
on = { item, _, _ -> item is MangaSourceItem && item.isGrid }, on = { item, _, _ -> item is MangaSourceItem && item.isGrid },
) { ) {
@@ -123,6 +142,7 @@ fun exploreSourceGridItemAD(
binding.root.setOnClickListener(eventListener) binding.root.setOnClickListener(eventListener)
binding.root.setOnLongClickListener(eventListener) binding.root.setOnLongClickListener(eventListener)
binding.root.setOnContextClickListenerCompat(eventListener)
bind { bind {
binding.textViewTitle.text = item.source.title binding.textViewTitle.text = item.source.title

View File

@@ -1,17 +1,16 @@
package org.koitharu.kotatsu.favourites.data package org.koitharu.kotatsu.favourites.data
import org.koitharu.kotatsu.core.db.entity.SortOrder
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import java.util.Date import java.util.Date
fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory( fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory(
id = id, id = id,
title = title, title = title,
sortKey = sortKey, sortKey = sortKey,
order = SortOrder(order, SortOrder.NEWEST), order = ListSortOrder(order, ListSortOrder.NEWEST),
createdAt = Date(createdAt), createdAt = Date(createdAt),
isTrackingEnabled = track, isTrackingEnabled = track,
isVisibleInLibrary = isVisibleInLibrary, isVisibleInLibrary = isVisibleInLibrary,

View File

@@ -1,13 +1,19 @@
package org.koitharu.kotatsu.favourites.data package org.koitharu.kotatsu.favourites.data
import androidx.room.* import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction
import androidx.room.Upsert
import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
@Dao @Dao
abstract class FavouritesDao { abstract class FavouritesDao {
@@ -22,7 +28,7 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit") @Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit")
abstract suspend fun findLast(limit: Int): List<FavouriteManga> abstract suspend fun findLast(limit: Int): List<FavouriteManga>
fun observeAll(order: SortOrder): Flow<List<FavouriteManga>> { fun observeAll(order: ListSortOrder): Flow<List<FavouriteManga>> {
val orderBy = getOrderBy(order) val orderBy = getOrderBy(order)
@Language("RoomSql") @Language("RoomSql")
@@ -47,7 +53,7 @@ abstract class FavouritesDao {
) )
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga> abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
fun observeAll(categoryId: Long, order: SortOrder): Flow<List<FavouriteManga>> { fun observeAll(categoryId: Long, order: ListSortOrder): Flow<List<FavouriteManga>> {
val orderBy = getOrderBy(order) val orderBy = getOrderBy(order)
@Language("RoomSql") @Language("RoomSql")
@@ -72,13 +78,14 @@ abstract class FavouritesDao {
) )
abstract suspend fun findAllManga(categoryId: Int): List<MangaEntity> abstract suspend fun findAllManga(categoryId: Int): List<MangaEntity>
suspend fun findCovers(categoryId: Long, order: SortOrder): List<Cover> { suspend fun findCovers(categoryId: Long, order: ListSortOrder): List<Cover> {
val orderBy = getOrderBy(order) val orderBy = getOrderBy(order)
@Language("RoomSql") @Language("RoomSql")
val query = SimpleSQLiteQuery( val query = SimpleSQLiteQuery(
"SELECT m.cover_url AS url, m.source AS source FROM favourites AS f LEFT JOIN manga AS m ON f.manga_id = m.manga_id " + "SELECT manga.cover_url AS url, manga.source AS source FROM favourites " +
"WHERE f.category_id = ? AND deleted_at = 0 ORDER BY $orderBy", "LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
"WHERE favourites.category_id = ? AND deleted_at = 0 ORDER BY $orderBy",
arrayOf<Any>(categoryId), arrayOf<Any>(categoryId),
) )
return findCoversImpl(query) return findCoversImpl(query)
@@ -157,13 +164,12 @@ abstract class FavouritesDao {
@Query("UPDATE favourites SET deleted_at = :deletedAt WHERE category_id = :categoryId AND deleted_at = 0") @Query("UPDATE favourites SET deleted_at = :deletedAt WHERE category_id = :categoryId AND deleted_at = 0")
protected abstract suspend fun setDeletedAtAll(categoryId: Long, deletedAt: Long) protected abstract suspend fun setDeletedAtAll(categoryId: Long, deletedAt: Long)
private fun getOrderBy(sortOrder: SortOrder) = when (sortOrder) { private fun getOrderBy(sortOrder: ListSortOrder) = when (sortOrder) {
SortOrder.RATING -> "rating DESC" ListSortOrder.RATING -> "manga.rating DESC"
SortOrder.NEWEST, ListSortOrder.NEWEST -> "favourites.created_at DESC"
SortOrder.UPDATED, ListSortOrder.ALPHABETIC -> "manga.title ASC"
-> "created_at DESC" ListSortOrder.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = manga.manga_id) DESC"
ListSortOrder.PROGRESS -> "(SELECT percent FROM history WHERE history.manga_id = manga.manga_id) DESC"
SortOrder.ALPHABETICAL -> "title ASC"
else -> throw IllegalArgumentException("Sort order $sortOrder is not supported") else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
} }
} }

View File

@@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.SortOrder
import org.koitharu.kotatsu.core.db.entity.toEntities import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
@@ -20,8 +19,8 @@ import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.favourites.data.toManga import org.koitharu.kotatsu.favourites.data.toManga
import org.koitharu.kotatsu.favourites.data.toMangaList import org.koitharu.kotatsu.favourites.data.toMangaList
import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
import javax.inject.Inject import javax.inject.Inject
@@ -41,7 +40,7 @@ class FavouritesRepository @Inject constructor(
return entities.toMangaList() return entities.toMangaList()
} }
fun observeAll(order: SortOrder): Flow<List<Manga>> { fun observeAll(order: ListSortOrder): Flow<List<Manga>> {
return db.favouritesDao.observeAll(order) return db.favouritesDao.observeAll(order)
.mapItems { it.toManga() } .mapItems { it.toManga() }
} }
@@ -51,7 +50,7 @@ class FavouritesRepository @Inject constructor(
return entities.toMangaList() return entities.toMangaList()
} }
fun observeAll(categoryId: Long, order: SortOrder): Flow<List<Manga>> { fun observeAll(categoryId: Long, order: ListSortOrder): Flow<List<Manga>> {
return db.favouritesDao.observeAll(categoryId, order) return db.favouritesDao.observeAll(categoryId, order)
.mapItems { it.toManga() } .mapItems { it.toManga() }
} }
@@ -105,7 +104,7 @@ class FavouritesRepository @Inject constructor(
suspend fun createCategory( suspend fun createCategory(
title: String, title: String,
sortOrder: SortOrder, sortOrder: ListSortOrder,
isTrackerEnabled: Boolean, isTrackerEnabled: Boolean,
isVisibleOnShelf: Boolean, isVisibleOnShelf: Boolean,
): FavouriteCategory { ): FavouriteCategory {
@@ -128,7 +127,7 @@ class FavouritesRepository @Inject constructor(
suspend fun updateCategory( suspend fun updateCategory(
id: Long, id: Long,
title: String, title: String,
sortOrder: SortOrder, sortOrder: ListSortOrder,
isTrackerEnabled: Boolean, isTrackerEnabled: Boolean,
isVisibleOnShelf: Boolean, isVisibleOnShelf: Boolean,
) { ) {
@@ -156,7 +155,7 @@ class FavouritesRepository @Inject constructor(
} }
} }
suspend fun setCategoryOrder(id: Long, order: SortOrder) { suspend fun setCategoryOrder(id: Long, order: ListSortOrder) {
db.favouriteCategoriesDao.updateOrder(id, order.name) db.favouriteCategoriesDao.updateOrder(id, order.name)
} }
@@ -205,10 +204,10 @@ class FavouritesRepository @Inject constructor(
return ReversibleHandle { recoverToCategory(categoryId, ids) } return ReversibleHandle { recoverToCategory(categoryId, ids) }
} }
private fun observeOrder(categoryId: Long): Flow<SortOrder> { private fun observeOrder(categoryId: Long): Flow<ListSortOrder> {
return db.favouriteCategoriesDao.observe(categoryId) return db.favouriteCategoriesDao.observe(categoryId)
.filterNotNull() .filterNotNull()
.map { x -> SortOrder(x.order, SortOrder.NEWEST) } .map { x -> ListSortOrder(x.order, ListSortOrder.NEWEST) }
.distinctUntilChanged() .distinctUntilChanged()
} }

View File

@@ -27,7 +27,6 @@ import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEdit
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.SortOrder
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -67,7 +66,7 @@ class FavouriteCategoriesActivity :
attachToRecyclerView(viewBinding.recyclerView) attachToRecyclerView(viewBinding.recyclerView)
} }
viewModel.categories.observe(this, ::onCategoriesChanged) viewModel.content.observe(this, ::onCategoriesChanged)
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
} }
@@ -176,12 +175,6 @@ class FavouriteCategoriesActivity :
companion object { companion object {
val SORT_ORDERS = arrayOf(
SortOrder.ALPHABETICAL,
SortOrder.NEWEST,
SortOrder.RATING,
)
fun newIntent(context: Context) = Intent(context, FavouriteCategoriesActivity::class.java) fun newIntent(context: Context) = Intent(context, FavouriteCategoriesActivity::class.java)
} }
} }

View File

@@ -1,21 +1,23 @@
package org.koitharu.kotatsu.favourites.ui.categories package org.koitharu.kotatsu.favourites.ui.categories
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.map import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.plus import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.util.move import org.koitharu.kotatsu.parsers.util.move
import javax.inject.Inject import javax.inject.Inject
@@ -26,28 +28,19 @@ class FavouritesCategoriesViewModel @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
) : BaseViewModel() { ) : BaseViewModel() {
private var reorderJob: Job? = null private var commitJob: Job? = null
val categories = repository.observeCategoriesWithCovers() val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
.map { list ->
list.map { (category, covers) -> init {
CategoryListModel( launchJob(Dispatchers.Default) {
mangaCount = covers.size, repository.observeCategoriesWithCovers()
covers = covers.take(3), .collectLatest {
category = category, commitJob?.join()
isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources, updateContent(it)
) }
}.ifEmpty { }
listOf( }
EmptyState(
icon = R.drawable.ic_empty_favourites,
textPrimary = R.string.text_empty_holder_primary,
textSecondary = R.string.empty_favourite_categories,
actionStringRes = 0,
),
)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
fun deleteCategories(ids: Set<Long>) { fun deleteCategories(ids: Set<Long>) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
@@ -59,19 +52,13 @@ class FavouritesCategoriesViewModel @Inject constructor(
settings.isAllFavouritesVisible = isVisible settings.isAllFavouritesVisible = isVisible
} }
fun isEmpty(): Boolean = categories.value.none { it is CategoryListModel } fun isEmpty(): Boolean = content.value.none { it is CategoryListModel }
fun reorderCategories(oldPos: Int, newPos: Int) { fun reorderCategories(oldPos: Int, newPos: Int) {
val prevJob = reorderJob val snapshot = content.requireValue().toMutableList()
reorderJob = launchJob(Dispatchers.Default) { snapshot.move(oldPos, newPos)
prevJob?.join() content.value = snapshot
val snapshot = categories.requireValue().toMutableList() commit(snapshot)
snapshot.move(oldPos, newPos)
val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) {
(it as? CategoryListModel)?.category?.id
}
repository.reorderCategories(ids)
}
} }
fun setIsVisible(ids: Set<Long>, isVisible: Boolean) { fun setIsVisible(ids: Set<Long>, isVisible: Boolean) {
@@ -83,9 +70,42 @@ class FavouritesCategoriesViewModel @Inject constructor(
} }
fun getCategories(ids: Set<Long>): ArrayList<FavouriteCategory> { fun getCategories(ids: Set<Long>): ArrayList<FavouriteCategory> {
val items = categories.requireValue() val items = content.requireValue()
return items.mapNotNullTo(ArrayList(ids.size)) { item -> return items.mapNotNullTo(ArrayList(ids.size)) { item ->
(item as? CategoryListModel)?.category?.takeIf { it.id in ids } (item as? CategoryListModel)?.category?.takeIf { it.id in ids }
} }
} }
private fun commit(snapshot: List<ListModel>) {
val prevJob = commitJob
commitJob = launchJob {
prevJob?.cancelAndJoin()
delay(500)
val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) {
(it as? CategoryListModel)?.category?.id
}
repository.reorderCategories(ids)
yield()
}
}
private fun updateContent(categories: Map<FavouriteCategory, List<Cover>>) {
content.value = categories.map { (category, covers) ->
CategoryListModel(
mangaCount = covers.size,
covers = covers.take(3),
category = category,
isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources,
)
}.ifEmpty {
listOf(
EmptyState(
icon = R.drawable.ic_empty_favourites,
textPrimary = R.string.text_empty_holder_primary,
textSecondary = R.string.empty_favourite_categories,
actionStringRes = 0,
),
)
}
}
} }

View File

@@ -18,16 +18,15 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getSerializableCompat import org.koitharu.kotatsu.core.util.ext.getSerializableCompat
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setChecked import org.koitharu.kotatsu.core.util.ext.setChecked
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.databinding.ActivityCategoryEditBinding import org.koitharu.kotatsu.databinding.ActivityCategoryEditBinding
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.parsers.model.SortOrder
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@AndroidEntryPoint @AndroidEntryPoint
@@ -38,7 +37,8 @@ class FavouritesCategoryEditActivity :
DefaultTextWatcher { DefaultTextWatcher {
private val viewModel by viewModels<FavouritesCategoryEditViewModel>() private val viewModel by viewModels<FavouritesCategoryEditViewModel>()
private var selectedSortOrder: SortOrder? = null private var selectedSortOrder: ListSortOrder? = null
private val sortOrders = ListSortOrder.FAVORITES.sortedByOrdinal()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -68,7 +68,7 @@ class FavouritesCategoryEditActivity :
override fun onRestoreInstanceState(savedInstanceState: Bundle) { override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState) super.onRestoreInstanceState(savedInstanceState)
val order = savedInstanceState.getSerializableCompat<SortOrder>(KEY_SORT_ORDER) val order = savedInstanceState.getSerializableCompat<ListSortOrder>(KEY_SORT_ORDER)
if (order != null) { if (order != null) {
selectedSortOrder = order selectedSortOrder = order
} }
@@ -103,7 +103,7 @@ class FavouritesCategoryEditActivity :
} }
override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
selectedSortOrder = FavouriteCategoriesActivity.SORT_ORDERS.getOrNull(position) selectedSortOrder = sortOrders.getOrNull(position)
} }
private fun onCategoryChanged(category: FavouriteCategory?) { private fun onCategoryChanged(category: FavouriteCategory?) {
@@ -113,7 +113,7 @@ class FavouritesCategoryEditActivity :
} }
viewBinding.editName.setText(category?.title) viewBinding.editName.setText(category?.title)
selectedSortOrder = category?.order selectedSortOrder = category?.order
val sortText = getString((category?.order ?: SortOrder.NEWEST).titleRes) val sortText = getString((category?.order ?: ListSortOrder.NEWEST).titleResId)
viewBinding.editSort.setText(sortText, false) viewBinding.editSort.setText(sortText, false)
viewBinding.switchTracker.setChecked(category?.isTrackingEnabled ?: true, false) viewBinding.switchTracker.setChecked(category?.isTrackingEnabled ?: true, false)
viewBinding.switchShelf.setChecked(category?.isVisibleInLibrary ?: true, false) viewBinding.switchShelf.setChecked(category?.isVisibleInLibrary ?: true, false)
@@ -135,17 +135,17 @@ class FavouritesCategoryEditActivity :
} }
private fun initSortSpinner() { private fun initSortSpinner() {
val entries = FavouriteCategoriesActivity.SORT_ORDERS.map { getString(it.titleRes) } val entries = sortOrders.map { getString(it.titleResId) }
val adapter = SortAdapter(this, entries) val adapter = SortAdapter(this, entries)
viewBinding.editSort.setAdapter(adapter) viewBinding.editSort.setAdapter(adapter)
viewBinding.editSort.onItemClickListener = this viewBinding.editSort.onItemClickListener = this
} }
private fun getSelectedSortOrder(): SortOrder { private fun getSelectedSortOrder(): ListSortOrder {
selectedSortOrder?.let { return it } selectedSortOrder?.let { return it }
val entries = FavouriteCategoriesActivity.SORT_ORDERS.map { getString(it.titleRes) } val entries = sortOrders.map { getString(it.titleResId) }
val index = entries.indexOf(viewBinding.editSort.text.toString()) val index = entries.indexOf(viewBinding.editSort.text.toString())
return FavouriteCategoriesActivity.SORT_ORDERS.getOrNull(index) ?: SortOrder.NEWEST return sortOrders.getOrNull(index) ?: ListSortOrder.NEWEST
} }
private class SortAdapter( private class SortAdapter(

View File

@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.EXTRA_ID import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.EXTRA_ID
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.NO_ID import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.NO_ID
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -48,7 +48,7 @@ class FavouritesCategoryEditViewModel @Inject constructor(
fun save( fun save(
title: String, title: String,
sortOrder: SortOrder, sortOrder: ListSortOrder,
isTrackerEnabled: Boolean, isTrackerEnabled: Boolean,
isVisibleOnShelf: Boolean, isVisibleOnShelf: Boolean,
) { ) {

View File

@@ -76,10 +76,7 @@ class FavouriteSheet :
putParcelableArrayList( putParcelableArrayList(
KEY_MANGA_LIST, KEY_MANGA_LIST,
manga.mapTo(ArrayList(manga.size)) { manga.mapTo(ArrayList(manga.size)) {
ParcelableManga( ParcelableManga(it)
it,
withChapters = false,
)
}, },
) )
}.showDistinct(fm, TAG) }.showDistinct(fm, TAG)

View File

@@ -10,13 +10,11 @@ import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -27,12 +25,14 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
override val isSwipeRefreshEnabled = false override val isSwipeRefreshEnabled = false
val categoryId
get() = viewModel.categoryId
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
if (viewModel.categoryId != NO_ID) { if (viewModel.categoryId != NO_ID) {
addMenuProvider(FavouritesListMenuProvider(binding.root.context, viewModel)) addMenuProvider(FavouritesListMenuProvider(binding.root.context, viewModel))
} }
viewModel.sortOrder.observe(viewLifecycleOwner, MenuInvalidator(requireActivity()))
} }
override fun onScrolledToEnd() = Unit override fun onScrolledToEnd() = Unit
@@ -40,14 +40,15 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
override fun onFilterClick(view: View?) { override fun onFilterClick(view: View?) {
val menu = PopupMenu(view?.context ?: return, view) val menu = PopupMenu(view?.context ?: return, view)
menu.setOnMenuItemClickListener(this) menu.setOnMenuItemClickListener(this)
for ((i, item) in FavouriteCategoriesActivity.SORT_ORDERS.withIndex()) { val orders = ListSortOrder.FAVORITES.sortedByOrdinal()
menu.menu.add(Menu.NONE, Menu.NONE, i, item.titleRes) for ((i, item) in orders.withIndex()) {
menu.menu.add(Menu.NONE, Menu.NONE, i, item.titleResId)
} }
menu.show() menu.show()
} }
override fun onMenuItemClick(item: MenuItem): Boolean { override fun onMenuItemClick(item: MenuItem): Boolean {
val order = FavouriteCategoriesActivity.SORT_ORDERS.getOrNull(item.order) ?: return false val order = ListSortOrder.FAVORITES.sortedByOrdinal().getOrNull(item.order) ?: return false
viewModel.setSortOrder(order) viewModel.setSortOrder(order)
return true return true
} }

View File

@@ -5,12 +5,8 @@ import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import androidx.core.view.forEach
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
import org.koitharu.kotatsu.parsers.model.SortOrder
class FavouritesListMenuProvider( class FavouritesListMenuProvider(
private val context: Context, private val context: Context,
@@ -19,34 +15,12 @@ class FavouritesListMenuProvider(
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_favourites, menu) menuInflater.inflate(R.menu.opt_favourites, menu)
val subMenu = menu.findItem(R.id.action_order)?.subMenu ?: return
for (order in FavouriteCategoriesActivity.SORT_ORDERS) {
subMenu.add(R.id.group_order, Menu.NONE, order.ordinal, order.titleRes)
}
subMenu.setGroupCheckable(R.id.group_order, true, true)
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
val order = viewModel.sortOrder.value ?: return
menu.findItem(R.id.action_order)?.subMenu?.forEach { item ->
if (item.order == order.ordinal) {
item.isChecked = true
}
}
} }
override fun onMenuItemSelected(menuItem: MenuItem): Boolean { override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
if (menuItem.groupId == R.id.group_order) {
val order = SortOrder.entries[menuItem.order]
viewModel.setSortOrder(order)
return true
}
return when (menuItem.itemId) { return when (menuItem.itemId) {
R.id.action_edit -> { R.id.action_edit -> {
context.startActivity( context.startActivity(FavouritesCategoryEditActivity.newIntent(context, viewModel.categoryId))
FavouritesCategoryEditActivity.newIntent(context, viewModel.categoryId),
)
true true
} }

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
@@ -21,12 +22,12 @@ import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.SortOrder
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -40,7 +41,10 @@ class FavouritesListViewModel @Inject constructor(
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
val sortOrder: StateFlow<SortOrder?> = if (categoryId == NO_ID) { override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_FAVORITES) { favoritesListMode }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.favoritesListMode)
val sortOrder: StateFlow<ListSortOrder?> = if (categoryId == NO_ID) {
MutableStateFlow(null) MutableStateFlow(null)
} else { } else {
repository.observeCategory(categoryId) repository.observeCategory(categoryId)
@@ -50,7 +54,7 @@ class FavouritesListViewModel @Inject constructor(
override val content = combine( override val content = combine(
if (categoryId == NO_ID) { if (categoryId == NO_ID) {
repository.observeAll(SortOrder.NEWEST) repository.observeAll(ListSortOrder.NEWEST)
} else { } else {
repository.observeAll(categoryId) repository.observeAll(categoryId)
}, },
@@ -94,7 +98,7 @@ class FavouritesListViewModel @Inject constructor(
} }
} }
fun setSortOrder(order: SortOrder) { fun setSortOrder(order: ListSortOrder) {
if (categoryId == NO_ID) { if (categoryId == NO_ID) {
return return
} }

View File

@@ -25,6 +25,7 @@ import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.lifecycleScope import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterItem import org.koitharu.kotatsu.filter.ui.model.FilterItem
import org.koitharu.kotatsu.filter.ui.model.FilterState import org.koitharu.kotatsu.filter.ui.model.FilterState
@@ -131,7 +132,7 @@ class FilterCoordinator @Inject constructor(
observeState(), observeState(),
observeAvailableTags(), observeAvailableTags(),
) { state, available -> ) { state, available ->
val chips = createChipsList(state, available.orEmpty()) val chips = createChipsList(state, available.orEmpty(), 8)
FilterHeaderModel(chips, state.sortOrder, state.tags.isNotEmpty()) FilterHeaderModel(chips, state.sortOrder, state.tags.isNotEmpty())
} }
@@ -157,11 +158,16 @@ class FilterCoordinator @Inject constructor(
private suspend fun createChipsList( private suspend fun createChipsList(
filterState: FilterState, filterState: FilterState,
availableTags: Set<MangaTag>, availableTags: Set<MangaTag>,
limit: Int,
): List<ChipsView.ChipModel> { ): List<ChipsView.ChipModel> {
val selectedTags = filterState.tags.toMutableSet() val selectedTags = filterState.tags.toMutableSet()
var tags = searchRepository.getTagsSuggestion("", 6, repository.source) var tags = if (selectedTags.isEmpty()) {
if (tags.isEmpty()) { searchRepository.getTagsSuggestion("", limit, repository.source)
tags = availableTags.take(6) } else {
searchRepository.getTagsSuggestion(selectedTags).take(limit)
}
if (tags.size < limit) {
tags = tags + availableTags.take(limit - tags.size)
} }
if (tags.isEmpty() && selectedTags.isEmpty()) { if (tags.isEmpty() && selectedTags.isEmpty()) {
return emptyList() return emptyList()
@@ -202,7 +208,7 @@ class FilterCoordinator @Inject constructor(
state: FilterState, state: FilterState,
query: String, query: String,
): List<ListModel> { ): List<ListModel> {
val sortOrders = repository.sortOrders.sortedBy { it.ordinal } val sortOrders = repository.sortOrders.sortedByOrdinal()
val tags = mergeTags(state.tags, allTags.tags).toList() val tags = mergeTags(state.tags, allTags.tags).toList()
val list = ArrayList<ListModel>(tags.size + sortOrders.size + 3) val list = ArrayList<ListModel>(tags.size + sortOrders.size + 3)
if (query.isEmpty()) { if (query.isEmpty()) {

View File

@@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.history.domain.model.HistoryOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
@Dao @Dao
abstract class HistoryDao { abstract class HistoryDao {
@@ -33,12 +33,14 @@ abstract class HistoryDao {
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit") @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit")
abstract fun observeAll(limit: Int): Flow<List<HistoryWithManga>> abstract fun observeAll(limit: Int): Flow<List<HistoryWithManga>>
fun observeAll(order: HistoryOrder): Flow<List<HistoryWithManga>> { fun observeAll(order: ListSortOrder): Flow<List<HistoryWithManga>> {
val orderBy = when (order) { val orderBy = when (order) {
HistoryOrder.UPDATED -> "history.updated_at DESC" ListSortOrder.UPDATED -> "history.updated_at DESC"
HistoryOrder.CREATED -> "history.created_at DESC" ListSortOrder.NEWEST -> "history.created_at DESC"
HistoryOrder.PROGRESS -> "history.percent DESC" ListSortOrder.PROGRESS -> "history.percent DESC"
HistoryOrder.ALPHABETIC -> "manga.title" ListSortOrder.ALPHABETIC -> "manga.title"
ListSortOrder.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = manga.manga_id) DESC"
else -> throw IllegalArgumentException("Sort order $order is not supported")
} }
@Language("RoomSql") @Language("RoomSql")

View File

@@ -14,11 +14,12 @@ import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTag import org.koitharu.kotatsu.core.db.entity.toMangaTag
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.core.util.ext.mapItems
import org.koitharu.kotatsu.history.domain.model.HistoryOrder
import org.koitharu.kotatsu.history.domain.model.MangaWithHistory import org.koitharu.kotatsu.history.domain.model.MangaWithHistory
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
@@ -65,7 +66,7 @@ class HistoryRepository @Inject constructor(
} }
} }
fun observeAllWithHistory(order: HistoryOrder): Flow<List<MangaWithHistory>> { fun observeAllWithHistory(order: ListSortOrder): Flow<List<MangaWithHistory>> {
return db.historyDao.observeAll(order).mapItems { return db.historyDao.observeAll(order).mapItems {
MangaWithHistory( MangaWithHistory(
it.manga.toManga(it.tags.toMangaTags()), it.manga.toManga(it.tags.toMangaTags()),
@@ -92,8 +93,11 @@ class HistoryRepository @Inject constructor(
} }
val tags = manga.tags.toEntities() val tags = manga.tags.toEntities()
db.withTransaction { db.withTransaction {
db.tagsDao.upsert(tags) val existing = db.mangaDao.find(manga.id)?.manga
db.mangaDao.upsert(manga.toEntity(), tags) if (existing == null || existing.source == manga.source.name) {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga.toEntity(), tags)
}
db.historyDao.upsert( db.historyDao.upsert(
HistoryEntity( HistoryEntity(
mangaId = manga.id, mangaId = manga.id,
@@ -107,7 +111,7 @@ class HistoryRepository @Inject constructor(
), ),
) )
trackingRepository.syncWithHistory(manga, chapterId) trackingRepository.syncWithHistory(manga, chapterId)
val chapter = manga.chapters?.find { x -> x.id == chapterId } val chapter = manga.chapters?.findById(chapterId)
if (chapter != null) { if (chapter != null) {
scrobblers.forEach { it.tryScrobble(manga.id, chapter) } scrobblers.forEach { it.tryScrobble(manga.id, chapter) }
} }
@@ -181,7 +185,7 @@ class HistoryRepository @Inject constructor(
private suspend fun HistoryEntity.recoverIfNeeded(manga: Manga): HistoryEntity { private suspend fun HistoryEntity.recoverIfNeeded(manga: Manga): HistoryEntity {
val chapters = manga.chapters val chapters = manga.chapters
if (chapters.isNullOrEmpty() || chapters.any { it.id == chapterId }) { if (chapters.isNullOrEmpty() || chapters.findById(chapterId) != null) {
return this return this
} }
val newChapterId = chapters.getOrNull( val newChapterId = chapters.getOrNull(

View File

@@ -1,16 +0,0 @@
package org.koitharu.kotatsu.history.domain.model
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
enum class HistoryOrder(
@StringRes val titleResId: Int,
) {
UPDATED(R.string.updated),
CREATED(R.string.order_added),
PROGRESS(R.string.progress),
ALPHABETIC(R.string.by_name);
fun isGroupingSupported() = this == UPDATED || this == CREATED || this == PROGRESS
}

View File

@@ -9,9 +9,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkManageIntent import org.koitharu.kotatsu.core.os.NetworkManageIntent
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
@@ -26,9 +24,6 @@ class HistoryListFragment : MangaListFragment() {
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
addMenuProvider(HistoryListMenuProvider(binding.root.context, viewModel)) addMenuProvider(HistoryListMenuProvider(binding.root.context, viewModel))
val menuInvalidator = MenuInvalidator(requireActivity())
viewModel.isGroupingEnabled.observe(viewLifecycleOwner, menuInvalidator)
viewModel.sortOrder.observe(viewLifecycleOwner, menuInvalidator)
} }
override fun onScrolledToEnd() = Unit override fun onScrolledToEnd() = Unit

View File

@@ -5,12 +5,10 @@ import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import androidx.core.view.forEach
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.dialog.RememberSelectionDialogListener import org.koitharu.kotatsu.core.ui.dialog.RememberSelectionDialogListener
import org.koitharu.kotatsu.core.util.ext.startOfDay import org.koitharu.kotatsu.core.util.ext.startOfDay
import org.koitharu.kotatsu.history.domain.model.HistoryOrder
import java.util.Date import java.util.Date
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@@ -22,47 +20,19 @@ class HistoryListMenuProvider(
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_history, menu) menuInflater.inflate(R.menu.opt_history, menu)
val subMenu = menu.findItem(R.id.action_order)?.subMenu ?: return
for (order in HistoryOrder.entries) {
subMenu.add(R.id.group_order, Menu.NONE, order.ordinal, order.titleResId)
}
subMenu.setGroupCheckable(R.id.group_order, true, true)
} }
override fun onMenuItemSelected(menuItem: MenuItem): Boolean { override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
if (menuItem.groupId == R.id.group_order) {
val order = HistoryOrder.entries[menuItem.order]
viewModel.setSortOrder(order)
return true
}
return when (menuItem.itemId) { return when (menuItem.itemId) {
R.id.action_clear_history -> { R.id.action_clear_history -> {
showClearHistoryDialog() showClearHistoryDialog()
true true
} }
R.id.action_history_grouping -> {
viewModel.setGrouping(!menuItem.isChecked)
true
}
else -> false else -> false
} }
} }
override fun onPrepareMenu(menu: Menu) {
val order = viewModel.sortOrder.value
menu.findItem(R.id.action_order)?.subMenu?.forEach { item ->
if (item.order == order.ordinal) {
item.isChecked = true
}
}
menu.findItem(R.id.action_history_grouping)?.run {
isChecked = viewModel.isGroupingEnabled.value == true
isEnabled = order.isGroupingSupported()
}
}
private fun showClearHistoryDialog() { private fun showClearHistoryDialog() {
val selectionListener = RememberSelectionDialogListener(2) val selectionListener = RememberSelectionDialogListener(2)
MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered) MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)

View File

@@ -26,9 +26,9 @@ import org.koitharu.kotatsu.core.util.ext.daysDiff
import org.koitharu.kotatsu.core.util.ext.onFirst import org.koitharu.kotatsu.core.util.ext.onFirst
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.domain.model.HistoryOrder
import org.koitharu.kotatsu.history.domain.model.MangaWithHistory import org.koitharu.kotatsu.history.domain.model.MangaWithHistory
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyHint import org.koitharu.kotatsu.list.ui.model.EmptyHint
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
@@ -54,22 +54,21 @@ class HistoryListViewModel @Inject constructor(
downloadScheduler: DownloadWorker.Scheduler, downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler) { ) : MangaListViewModel(settings, downloadScheduler) {
val sortOrder: StateFlow<HistoryOrder> = settings.observeAsStateFlow( private val sortOrder: StateFlow<ListSortOrder> = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.IO, scope = viewModelScope + Dispatchers.IO,
key = AppSettings.KEY_HISTORY_ORDER, key = AppSettings.KEY_HISTORY_ORDER,
valueProducer = { historySortOrder }, valueProducer = { historySortOrder },
) )
val isGroupingEnabled = settings.observeAsFlow( override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_HISTORY) { historyListMode }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.historyListMode)
private val isGroupingEnabled = settings.observeAsFlow(
key = AppSettings.KEY_HISTORY_GROUPING, key = AppSettings.KEY_HISTORY_GROUPING,
valueProducer = { isHistoryGroupingEnabled }, valueProducer = { isHistoryGroupingEnabled },
).combine(sortOrder) { g, s -> ).combine(sortOrder) { g, s ->
g && s.isGroupingSupported() g && s.isGroupingSupported()
}.stateIn( }
scope = viewModelScope + Dispatchers.Default,
started = SharingStarted.Eagerly,
initialValue = settings.isHistoryGroupingEnabled && sortOrder.value.isGroupingSupported(),
)
override val content = combine( override val content = combine(
sortOrder.flatMapLatest { repository.observeAllWithHistory(it) }, sortOrder.flatMapLatest { repository.observeAllWithHistory(it) },
@@ -101,10 +100,6 @@ class HistoryListViewModel @Inject constructor(
override fun onRetry() = Unit override fun onRetry() = Unit
fun setSortOrder(order: HistoryOrder) {
settings.historySortOrder = order
}
fun clearHistory(minDate: Long) { fun clearHistory(minDate: Long) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val stringRes = if (minDate <= 0) { val stringRes = if (minDate <= 0) {
@@ -128,10 +123,6 @@ class HistoryListViewModel @Inject constructor(
} }
} }
fun setGrouping(isGroupingEnabled: Boolean) {
settings.isHistoryGroupingEnabled = isGroupingEnabled
}
private suspend fun mapList( private suspend fun mapList(
list: List<MangaWithHistory>, list: List<MangaWithHistory>,
grouped: Boolean, grouped: Boolean,
@@ -173,10 +164,10 @@ class HistoryListViewModel @Inject constructor(
return result return result
} }
private fun MangaHistory.header(order: HistoryOrder): ListHeader? = when (order) { private fun MangaHistory.header(order: ListSortOrder): ListHeader? = when (order) {
HistoryOrder.UPDATED -> ListHeader(timeAgo(updatedAt)) ListSortOrder.UPDATED -> ListHeader(timeAgo(updatedAt))
HistoryOrder.CREATED -> ListHeader(timeAgo(createdAt)) ListSortOrder.NEWEST -> ListHeader(timeAgo(createdAt))
HistoryOrder.PROGRESS -> ListHeader( ListSortOrder.PROGRESS -> ListHeader(
when (percent) { when (percent) {
1f -> R.string.status_completed 1f -> R.string.status_completed
in 0f..0.01f -> R.string.status_planned in 0f..0.01f -> R.string.status_planned
@@ -185,7 +176,10 @@ class HistoryListViewModel @Inject constructor(
}, },
) )
HistoryOrder.ALPHABETIC -> null ListSortOrder.ALPHABETIC,
ListSortOrder.RELEVANCE,
ListSortOrder.NEW_CHAPTERS,
ListSortOrder.RATING -> null
} }
private fun timeAgo(date: Date): DateTimeAgo { private fun timeAgo(date: Date): DateTimeAgo {

View File

@@ -0,0 +1,31 @@
package org.koitharu.kotatsu.list.domain
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.find
import java.util.EnumSet
enum class ListSortOrder(
@StringRes val titleResId: Int,
) {
UPDATED(R.string.updated),
NEWEST(R.string.order_added),
PROGRESS(R.string.progress),
ALPHABETIC(R.string.by_name),
RATING(R.string.by_rating),
RELEVANCE(R.string.by_relevance),
NEW_CHAPTERS(R.string.new_chapters),
;
fun isGroupingSupported() = this == UPDATED || this == NEWEST || this == PROGRESS
companion object {
val HISTORY: Set<ListSortOrder> = EnumSet.of(UPDATED, NEWEST, PROGRESS, ALPHABETIC, NEW_CHAPTERS)
val FAVORITES: Set<ListSortOrder> = EnumSet.of(ALPHABETIC, NEWEST, RATING, NEW_CHAPTERS, PROGRESS)
val SUGGESTIONS: Set<ListSortOrder> = EnumSet.of(RELEVANCE)
operator fun invoke(value: String, fallback: ListSortOrder) = entries.find(value) ?: fallback
}
}

View File

@@ -1,78 +0,0 @@
package org.koitharu.kotatsu.list.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import com.google.android.material.button.MaterialButtonToggleGroup
import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter
import org.koitharu.kotatsu.databinding.DialogListModeBinding
import javax.inject.Inject
@AndroidEntryPoint
class ListModeBottomSheet :
BaseAdaptiveSheet<DialogListModeBinding>(),
Slider.OnChangeListener,
MaterialButtonToggleGroup.OnButtonCheckedListener {
@Inject
lateinit var settings: AppSettings
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
) = DialogListModeBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: DialogListModeBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
val mode = settings.listMode
binding.buttonList.isChecked = mode == ListMode.LIST
binding.buttonListDetailed.isChecked = mode == ListMode.DETAILED_LIST
binding.buttonGrid.isChecked = mode == ListMode.GRID
binding.textViewGridTitle.isVisible = mode == ListMode.GRID
binding.sliderGrid.isVisible = mode == ListMode.GRID
binding.sliderGrid.setLabelFormatter(IntPercentLabelFormatter(binding.root.context))
binding.sliderGrid.setValueRounded(settings.gridSize.toFloat())
binding.sliderGrid.addOnChangeListener(this)
binding.checkableGroup.addOnButtonCheckedListener(this)
}
override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) {
if (!isChecked) {
return
}
val mode = when (checkedId) {
R.id.button_list -> ListMode.LIST
R.id.button_list_detailed -> ListMode.DETAILED_LIST
R.id.button_grid -> ListMode.GRID
else -> return
}
requireViewBinding().textViewGridTitle.isVisible = mode == ListMode.GRID
requireViewBinding().sliderGrid.isVisible = mode == ListMode.GRID
settings.listMode = mode
}
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
if (fromUser) {
settings.gridSize = value.toInt()
}
}
companion object {
private const val TAG = "ListModeSelectDialog"
fun show(fm: FragmentManager) = ListModeBottomSheet().showDistinct(fm, TAG)
}
}

View File

@@ -6,6 +6,11 @@ import android.view.MenuItem
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment
import org.koitharu.kotatsu.history.ui.HistoryListFragment
import org.koitharu.kotatsu.list.ui.config.ListConfigBottomSheet
import org.koitharu.kotatsu.list.ui.config.ListConfigSection
import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment
class MangaListMenuProvider( class MangaListMenuProvider(
private val fragment: Fragment, private val fragment: Fragment,
@@ -17,7 +22,13 @@ class MangaListMenuProvider(
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_list_mode -> { R.id.action_list_mode -> {
ListModeBottomSheet.show(fragment.childFragmentManager) val section: ListConfigSection = when (fragment) {
is HistoryListFragment -> ListConfigSection.History
is SuggestionsFragment -> ListConfigSection.Suggestions
is FavouritesListFragment -> ListConfigSection.Favorites(fragment.categoryId)
else -> ListConfigSection.General
}
ListConfigBottomSheet.show(fragment.childFragmentManager, section)
true true
} }

View File

@@ -24,7 +24,7 @@ abstract class MangaListViewModel(
) : BaseViewModel() { ) : BaseViewModel() {
abstract val content: StateFlow<List<ListModel>> abstract val content: StateFlow<List<ListModel>>
val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE) { listMode } open val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE) { listMode }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.listMode) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.listMode)
val onActionDone = MutableEventFlow<ReversibleAction>() val onActionDone = MutableEventFlow<ReversibleAction>()
val gridScale = settings.observeAsStateFlow( val gridScale = settings.observeAsStateFlow(
@@ -34,6 +34,9 @@ abstract class MangaListViewModel(
) )
val onDownloadStarted = MutableEventFlow<Unit>() val onDownloadStarted = MutableEventFlow<Unit>()
val isIncognitoModeEnabled: Boolean
get() = settings.isIncognitoModeEnabled
open fun onUpdateFilter(tags: Set<MangaTag>) = Unit open fun onUpdateFilter(tags: Set<MangaTag>) = Unit
abstract fun onRefresh() abstract fun onRefresh()

View File

@@ -25,4 +25,5 @@ enum class ListItemType {
DOWNLOAD, DOWNLOAD,
CATEGORY_LARGE, CATEGORY_LARGE,
MANGA_SCROBBLING, MANGA_SCROBBLING,
NAV_ITEM,
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.list.ui.adapter package org.koitharu.kotatsu.list.ui.adapter
import android.view.View
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
@@ -10,6 +11,7 @@ import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemMangaGridBinding import org.koitharu.kotatsu.databinding.ItemMangaGridBinding
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
@@ -28,12 +30,13 @@ fun mangaGridItemAD(
) { ) {
var badge: BadgeDrawable? = null var badge: BadgeDrawable? = null
itemView.setOnClickListener { val eventListener = object : View.OnClickListener, View.OnLongClickListener {
clickListener.onItemClick(item.manga, it) override fun onClick(v: View) = clickListener.onItemClick(item.manga, v)
} override fun onLongClick(v: View): Boolean = clickListener.onItemLongClick(item.manga, v)
itemView.setOnLongClickListener {
clickListener.onItemLongClick(item.manga, it)
} }
itemView.setOnClickListener(eventListener)
itemView.setOnLongClickListener(eventListener)
itemView.setOnContextClickListenerCompat(eventListener)
sizeResolver.attachToView(lifecycleOwner, itemView, binding.textViewTitle, binding.progressView) sizeResolver.attachToView(lifecycleOwner, itemView, binding.textViewTitle, binding.progressView)
bind { payloads -> bind { payloads ->

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding
@@ -45,6 +46,7 @@ fun mangaListDetailedItemAD(
} }
itemView.setOnClickListener(listenerAdapter) itemView.setOnClickListener(listenerAdapter)
itemView.setOnLongClickListener(listenerAdapter) itemView.setOnLongClickListener(listenerAdapter)
itemView.setOnContextClickListenerCompat(listenerAdapter)
binding.buttonRead.setOnClickListener(listenerAdapter) binding.buttonRead.setOnClickListener(listenerAdapter)
binding.chipsTags.onChipClickListener = listenerAdapter binding.chipsTags.onChipClickListener = listenerAdapter

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.list.ui.adapter package org.koitharu.kotatsu.list.ui.adapter
import android.view.View
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
@@ -9,6 +10,7 @@ import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemMangaListBinding import org.koitharu.kotatsu.databinding.ItemMangaListBinding
@@ -25,12 +27,13 @@ fun mangaListItemAD(
) { ) {
var badge: BadgeDrawable? = null var badge: BadgeDrawable? = null
itemView.setOnClickListener { val eventListener = object : View.OnClickListener, View.OnLongClickListener {
clickListener.onItemClick(item.manga, it) override fun onClick(v: View) = clickListener.onItemClick(item.manga, v)
} override fun onLongClick(v: View): Boolean = clickListener.onItemLongClick(item.manga, v)
itemView.setOnLongClickListener {
clickListener.onItemLongClick(item.manga, it)
} }
itemView.setOnClickListener(eventListener)
itemView.setOnLongClickListener(eventListener)
itemView.setOnContextClickListenerCompat(eventListener)
bind { bind {
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title

View File

@@ -13,34 +13,39 @@ class TypedListSpacingDecoration(
) : ItemDecoration() { ) : ItemDecoration() {
private val spacingSmall = context.resources.getDimensionPixelOffset(R.dimen.list_spacing_small) private val spacingSmall = context.resources.getDimensionPixelOffset(R.dimen.list_spacing_small)
private val spacingNormal = context.resources.getDimensionPixelOffset(R.dimen.list_spacing_normal) private val spacingNormal =
context.resources.getDimensionPixelOffset(R.dimen.list_spacing_normal)
private val spacingLarge = context.resources.getDimensionPixelOffset(R.dimen.list_spacing_large) private val spacingLarge = context.resources.getDimensionPixelOffset(R.dimen.list_spacing_large)
override fun getItemOffsets( override fun getItemOffsets(
outRect: Rect, outRect: Rect,
view: View, view: View,
parent: RecyclerView, parent: RecyclerView,
state: RecyclerView.State state: RecyclerView.State,
) { ) {
val itemType = parent.getChildViewHolder(view)?.itemViewType?.let { val itemType = parent.getChildViewHolder(view)?.itemViewType?.let {
ListItemType.entries.getOrNull(it) ListItemType.entries.getOrNull(it)
} }
when (itemType) { when (itemType) {
ListItemType.FILTER_SORT, ListItemType.FILTER_SORT,
ListItemType.FILTER_TAG -> outRect.set(0) ListItemType.FILTER_TAG,
-> outRect.set(0)
ListItemType.HEADER, ListItemType.HEADER,
ListItemType.FEED, ListItemType.FEED,
ListItemType.EXPLORE_SOURCE_LIST, ListItemType.EXPLORE_SOURCE_LIST,
ListItemType.MANGA_SCROBBLING, ListItemType.MANGA_SCROBBLING,
ListItemType.MANGA_LIST -> outRect.set(0) ListItemType.MANGA_LIST,
-> outRect.set(0)
ListItemType.DOWNLOAD, ListItemType.DOWNLOAD,
ListItemType.HINT_EMPTY, ListItemType.HINT_EMPTY,
ListItemType.MANGA_LIST_DETAILED -> outRect.set(spacingNormal) ListItemType.MANGA_LIST_DETAILED,
-> outRect.set(spacingNormal)
ListItemType.PAGE_THUMB, ListItemType.PAGE_THUMB,
ListItemType.MANGA_GRID -> outRect.set(spacingNormal) ListItemType.MANGA_GRID,
-> outRect.set(spacingNormal)
ListItemType.EXPLORE_BUTTONS -> outRect.set(spacingNormal) ListItemType.EXPLORE_BUTTONS -> outRect.set(spacingNormal)
@@ -53,7 +58,9 @@ class TypedListSpacingDecoration(
ListItemType.EXPLORE_SUGGESTION, ListItemType.EXPLORE_SUGGESTION,
ListItemType.MANGA_NESTED_GROUP, ListItemType.MANGA_NESTED_GROUP,
ListItemType.CATEGORY_LARGE, ListItemType.CATEGORY_LARGE,
null -> outRect.set(0) ListItemType.NAV_ITEM,
null,
-> outRect.set(0)
ListItemType.TIP -> outRect.set(0) // TODO ListItemType.TIP -> outRect.set(0) // TODO
} }
@@ -70,6 +77,6 @@ class TypedListSpacingDecoration(
private fun Rect.set(spacing: Int) = set(spacing, spacing, spacing, spacing) private fun Rect.set(spacing: Int) = set(spacing, spacing, spacing, spacing)
private fun ListItemType?.isEdgeToEdge() = this == ListItemType.MANGA_NESTED_GROUP private fun ListItemType?.isEdgeToEdge() = this == ListItemType.MANGA_NESTED_GROUP
|| this == ListItemType.FILTER_SORT || this == ListItemType.FILTER_SORT
|| this == ListItemType.FILTER_TAG || this == ListItemType.FILTER_TAG
} }

View File

@@ -0,0 +1,132 @@
package org.koitharu.kotatsu.list.ui.config
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.CompoundButton
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import com.google.android.material.button.MaterialButtonToggleGroup
import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter
import org.koitharu.kotatsu.databinding.SheetListModeBinding
import javax.inject.Inject
@AndroidEntryPoint
class ListConfigBottomSheet :
BaseAdaptiveSheet<SheetListModeBinding>(),
Slider.OnChangeListener,
MaterialButtonToggleGroup.OnButtonCheckedListener, CompoundButton.OnCheckedChangeListener,
AdapterView.OnItemSelectedListener {
@Inject
@Deprecated("")
lateinit var settings: AppSettings
private val viewModel by viewModels<ListConfigViewModel>()
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
) = SheetListModeBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: SheetListModeBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
val mode = viewModel.listMode
binding.buttonList.isChecked = mode == ListMode.LIST
binding.buttonListDetailed.isChecked = mode == ListMode.DETAILED_LIST
binding.buttonGrid.isChecked = mode == ListMode.GRID
binding.textViewGridTitle.isVisible = mode == ListMode.GRID
binding.sliderGrid.isVisible = mode == ListMode.GRID
binding.sliderGrid.setLabelFormatter(IntPercentLabelFormatter(binding.root.context))
binding.sliderGrid.setValueRounded(viewModel.gridSize.toFloat())
binding.sliderGrid.addOnChangeListener(this)
binding.checkableGroup.addOnButtonCheckedListener(this)
binding.switchGrouping.isVisible = viewModel.isGroupingAvailable
if (viewModel.isGroupingAvailable) {
binding.switchGrouping.isEnabled = settings.historySortOrder.isGroupingSupported()
}
binding.switchGrouping.isChecked = settings.isHistoryGroupingEnabled
binding.switchGrouping.setOnCheckedChangeListener(this)
val sortOrders = viewModel.getSortOrders()
if (sortOrders != null) {
binding.textViewOrderTitle.isVisible = true
binding.spinnerOrder.adapter = ArrayAdapter(
binding.spinnerOrder.context,
android.R.layout.simple_spinner_dropdown_item,
android.R.id.text1,
sortOrders.map { binding.spinnerOrder.context.getString(it.titleResId) },
)
val selected = sortOrders.indexOf(viewModel.getSelectedSortOrder())
if (selected >= 0) {
binding.spinnerOrder.setSelection(selected, false)
}
binding.spinnerOrder.onItemSelectedListener = this
binding.cardOrder.isVisible = true
}
}
override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) {
if (!isChecked) {
return
}
val mode = when (checkedId) {
R.id.button_list -> ListMode.LIST
R.id.button_list_detailed -> ListMode.DETAILED_LIST
R.id.button_grid -> ListMode.GRID
else -> return
}
requireViewBinding().textViewGridTitle.isVisible = mode == ListMode.GRID
requireViewBinding().sliderGrid.isVisible = mode == ListMode.GRID
viewModel.listMode = mode
}
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
when (buttonView.id) {
R.id.switch_grouping -> settings.isHistoryGroupingEnabled = isChecked
}
}
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
if (fromUser) {
viewModel.gridSize = value.toInt()
}
}
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
when (parent.id) {
R.id.spinner_order -> {
viewModel.setSortOrder(position)
viewBinding?.switchGrouping?.isEnabled = settings.historySortOrder.isGroupingSupported()
}
}
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
companion object {
private const val TAG = "ListModeSelectDialog"
const val ARG_SECTION = "section"
fun show(fm: FragmentManager, section: ListConfigSection) = ListConfigBottomSheet().withArgs(1) {
putParcelable(ARG_SECTION, section)
}.showDistinct(fm, TAG)
}
}

View File

@@ -0,0 +1,21 @@
package org.koitharu.kotatsu.list.ui.config
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
sealed interface ListConfigSection : Parcelable {
@Parcelize
data object History : ListConfigSection
@Parcelize
data object General : ListConfigSection
@Parcelize
data class Favorites(
val categoryId: Long,
) : ListConfigSection
@Parcelize
data object Suggestions : ListConfigSection
}

View File

@@ -0,0 +1,76 @@
package org.koitharu.kotatsu.list.ui.config
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.list.domain.ListSortOrder
import javax.inject.Inject
@HiltViewModel
class ListConfigViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val settings: AppSettings,
private val favouritesRepository: FavouritesRepository,
) : BaseViewModel() {
val section = savedStateHandle.require<ListConfigSection>(ListConfigBottomSheet.ARG_SECTION)
var listMode: ListMode
get() = when (section) {
is ListConfigSection.Favorites -> settings.favoritesListMode
ListConfigSection.General -> settings.listMode
ListConfigSection.History -> settings.historyListMode
ListConfigSection.Suggestions -> settings.suggestionsListMode
}
set(value) {
when (section) {
is ListConfigSection.Favorites -> settings.favoritesListMode = value
ListConfigSection.General -> settings.listMode = value
ListConfigSection.History -> settings.historyListMode = value
ListConfigSection.Suggestions -> settings.suggestionsListMode = value
}
}
var gridSize: Int
get() = settings.gridSize
set(value) {
settings.gridSize = value
}
val isGroupingAvailable: Boolean
get() = section == ListConfigSection.History
fun getSortOrders(): List<ListSortOrder>? = when (section) {
is ListConfigSection.Favorites -> ListSortOrder.FAVORITES
ListConfigSection.General -> null
ListConfigSection.History -> ListSortOrder.HISTORY
ListConfigSection.Suggestions -> ListSortOrder.SUGGESTIONS
}?.sortedByOrdinal()
fun getSelectedSortOrder(): ListSortOrder? = when (section) {
is ListConfigSection.Favorites -> runBlocking { favouritesRepository.getCategory(section.categoryId).order }
ListConfigSection.General -> null
ListConfigSection.History -> settings.historySortOrder
ListConfigSection.Suggestions -> ListSortOrder.RELEVANCE // TODO
}
fun setSortOrder(position: Int) {
val value = getSortOrders()?.getOrNull(position) ?: return
when (section) {
is ListConfigSection.Favorites -> launchJob {
favouritesRepository.setCategoryOrder(section.categoryId, value)
}
ListConfigSection.General -> Unit
ListConfigSection.History -> settings.historySortOrder = value
ListConfigSection.Suggestions -> Unit
}
}
}

View File

@@ -14,7 +14,7 @@ import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.CompositeMutex import org.koitharu.kotatsu.core.util.CompositeMutex2
import org.koitharu.kotatsu.core.util.ext.children import org.koitharu.kotatsu.core.util.ext.children
import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.filterWith import org.koitharu.kotatsu.core.util.ext.filterWith
@@ -45,7 +45,7 @@ class LocalMangaRepository @Inject constructor(
) : MangaRepository { ) : MangaRepository {
override val source = MangaSource.LOCAL override val source = MangaSource.LOCAL
private val locks = CompositeMutex<Long>() private val locks = CompositeMutex2<Long>()
override val sortOrders: Set<SortOrder> = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST) override val sortOrders: Set<SortOrder> = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST)
@@ -122,14 +122,18 @@ class LocalMangaRepository @Inject constructor(
suspend fun getRemoteManga(localManga: Manga): Manga? { suspend fun getRemoteManga(localManga: Manga): Manga? {
return runCatchingCancellable { return runCatchingCancellable {
LocalMangaInput.of(localManga).getMangaInfo() LocalMangaInput.of(localManga).getMangaInfo()?.takeUnless { it.isLocal }
}.onFailure { }.onFailure {
it.printStackTraceDebug() it.printStackTraceDebug()
}.getOrNull() }.getOrNull()
} }
suspend fun findSavedManga(remoteManga: Manga): LocalManga? { suspend fun findSavedManga(remoteManga: Manga): LocalManga? {
// TODO fast path by name // fast path
LocalMangaInput.find(storageManager.getReadableDirs(), remoteManga)?.let {
return it.getManga()
}
// slow path
val files = getAllFiles() val files = getAllFiles()
return channelFlow { return channelFlow {
for (file in files) { for (file in files) {

View File

@@ -4,6 +4,7 @@ import androidx.annotation.WorkerThread
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -21,7 +22,8 @@ class MangaIndex(source: String?) {
private val json: JSONObject = source?.let(::JSONObject) ?: JSONObject() private val json: JSONObject = source?.let(::JSONObject) ?: JSONObject()
fun setMangaInfo(manga: Manga, append: Boolean) { fun setMangaInfo(manga: Manga) {
require(!manga.isLocal) { "Local manga information cannot be stored" }
json.put("id", manga.id) json.put("id", manga.id)
json.put("title", manga.title) json.put("title", manga.title)
json.put("title_alt", manga.altTitle) json.put("title_alt", manga.altTitle)
@@ -46,7 +48,7 @@ class MangaIndex(source: String?) {
} }
}, },
) )
if (!append || !json.has("chapters")) { if (!json.has("chapters")) {
json.put("chapters", JSONObject()) json.put("chapters", JSONObject())
} }
json.put("app_id", BuildConfig.APPLICATION_ID) json.put("app_id", BuildConfig.APPLICATION_ID)

View File

@@ -5,6 +5,7 @@ import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.util.AlphanumComparator import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.creationTime
import org.koitharu.kotatsu.core.util.ext.listFilesRecursive import org.koitharu.kotatsu.core.util.ext.listFilesRecursive
import org.koitharu.kotatsu.core.util.ext.longHashCode import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.core.util.ext.toListSorted import org.koitharu.kotatsu.core.util.ext.toListSorted
@@ -62,7 +63,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
name = f.nameWithoutExtension.toHumanReadable(), name = f.nameWithoutExtension.toHumanReadable(),
number = i + 1, number = i + 1,
source = MangaSource.LOCAL, source = MangaSource.LOCAL,
uploadDate = f.lastModified(), uploadDate = f.creationTime,
url = f.toUri().toString(), url = f.toUri().toString(),
scanlator = null, scanlator = null,
branch = null, branch = null,

View File

@@ -2,12 +2,18 @@ package org.koitharu.kotatsu.local.data.input
import android.net.Uri import android.net.Uri
import androidx.core.net.toFile import androidx.core.net.toFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import java.io.File import java.io.File
sealed class LocalMangaInput( sealed class LocalMangaInput(
@@ -37,6 +43,24 @@ sealed class LocalMangaInput(
else -> null else -> null
} }
suspend fun find(roots: Iterable<File>, manga: Manga): LocalMangaInput? = channelFlow {
val fileName = manga.title.toFileNameSafe()
for (root in roots) {
launch {
val dir = File(root, fileName)
val zip = File(root, "$fileName.cbz")
val input = when {
dir.isDirectory -> LocalMangaDirInput(dir)
zip.isFile -> LocalMangaZipInput(zip)
else -> null
}
if (input?.getMangaInfo()?.id == manga.id) {
send(input)
}
}
}
}.flowOn(Dispatchers.Default).firstOrNull()
@JvmStatic @JvmStatic
protected fun zipUri(file: File, entryName: String): String = protected fun zipUri(file: File, entryName: String): String =
Uri.fromParts("cbz", file.path, entryName).toString() Uri.fromParts("cbz", file.path, entryName).toString()

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.local.data.output
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.takeIfReadable import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.zip.ZipOutput import org.koitharu.kotatsu.core.zip.ZipOutput
@@ -20,7 +22,9 @@ class LocalMangaDirOutput(
private val index = MangaIndex(File(rootFile, ENTRY_NAME_INDEX).takeIfReadable()?.readText()) private val index = MangaIndex(File(rootFile, ENTRY_NAME_INDEX).takeIfReadable()?.readText())
init { init {
index.setMangaInfo(manga, append = true) if (!manga.isLocal) {
index.setMangaInfo(manga)
}
} }
override suspend fun mergeWithExisting() = Unit override suspend fun mergeWithExisting() = Unit
@@ -87,7 +91,7 @@ class LocalMangaDirOutput(
suspend fun deleteChapter(chapterId: Long) { suspend fun deleteChapter(chapterId: Long) {
val chapter = checkNotNull(index.getMangaInfo()?.chapters) { val chapter = checkNotNull(index.getMangaInfo()?.chapters) {
"No chapters found" "No chapters found"
}.first { it.id == chapterId } }.findById(chapterId) ?: error("Chapter not found")
val chapterDir = File(rootFile, chapterFileName(chapter)) val chapterDir = File(rootFile, chapterFileName(chapter))
chapterDir.deleteAwait() chapterDir.deleteAwait()
index.removeChapter(chapterId) index.removeChapter(chapterId)

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.local.data.output
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.readText import org.koitharu.kotatsu.core.util.ext.readText
import org.koitharu.kotatsu.core.zip.ZipOutput import org.koitharu.kotatsu.core.zip.ZipOutput
@@ -21,7 +22,9 @@ class LocalMangaZipOutput(
private val index = MangaIndex(null) private val index = MangaIndex(null)
init { init {
index.setMangaInfo(manga, false) if (!manga.isLocal) {
index.setMangaInfo(manga)
}
} }
override suspend fun mergeWithExisting() { override suspend fun mergeWithExisting() {

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.local.domain.model
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import org.koitharu.kotatsu.core.util.ext.creationTime
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import java.io.File import java.io.File
@@ -15,7 +16,7 @@ data class LocalManga(
private set private set
get() { get() {
if (field == -1L) { if (field == -1L) {
field = file.lastModified() field = file.creationTime
} }
return field return field
} }

View File

@@ -11,6 +11,7 @@ import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.databinding.DialogImportBinding import org.koitharu.kotatsu.databinding.DialogImportBinding
class ImportDialogFragment : AlertDialogFragment<DialogImportBinding>(), View.OnClickListener { class ImportDialogFragment : AlertDialogFragment<DialogImportBinding>(), View.OnClickListener {
@@ -40,9 +41,13 @@ class ImportDialogFragment : AlertDialogFragment<DialogImportBinding>(), View.On
} }
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { val res = when (v.id) {
R.id.button_file -> importFileCall.launch(arrayOf("*/*")) R.id.button_file -> importFileCall.tryLaunch(arrayOf("*/*"))
R.id.button_dir -> importDirCall.launch(null) R.id.button_dir -> importDirCall.tryLaunch(null)
else -> true
}
if (!res) {
Toast.makeText(v.context, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
} }
} }

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