Compare commits

...

54 Commits

Author SHA1 Message Date
5fbae1256b Fix branch detection #143 2022-04-17 09:58:52 +03:00
d61ba80bf6 Add additional checks to download task #50 2022-04-17 09:47:21 +03:00
74c9fa9488 Option to save manga from history and favourites lists 2022-04-17 09:15:16 +03:00
ce732ccca0 Merge branch 'weblate-kotatsu-strings' into devel 2022-04-16 10:05:37 +03:00
Sergio Varela
6b99e360e0 Added translation using Weblate (Basque)
Added translation using Weblate (Basque)

Co-authored-by: Sergio Varela <sergitroll9@gmail.com>
2022-04-16 09:46:16 +03:00
lowak
1c73d54a94 Translated using Weblate (Swedish)
Currently translated at 100.0% (274 of 274 strings)

Translated using Weblate (Swedish)

Currently translated at 41.6% (114 of 274 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (8 of 8 strings)

Added translation using Weblate (Swedish)

Added translation using Weblate (Swedish)

Co-authored-by: lowak <lowak@pm.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/sv/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sv/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-16 09:46:16 +03:00
Luiz-bro
36e21caf96 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (274 of 274 strings)

Co-authored-by: Luiz-bro <luiznneto1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-16 09:46:16 +03:00
Santiago José Gutiérrez Llanos
f7f9c53466 Translated using Weblate (Spanish)
Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (274 of 274 strings)

Co-authored-by: Santiago José Gutiérrez Llanos <gutierrezapata17@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-16 09:46:16 +03:00
Allan Nordhøy
3f2ee2a925 Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (8 of 8 strings)

Added translation using Weblate (Norwegian Bokmål)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/nb_NO/
Translation: Kotatsu/plurals
2022-04-16 09:46:16 +03:00
J. Lavoie
b1c069f62f Translated using Weblate (Italian)
Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (German)

Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (French)

Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Finnish)

Currently translated at 99.2% (272 of 274 strings)

Translated using Weblate (French)

Currently translated at 100.0% (274 of 274 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (274 of 274 strings)

Translated using Weblate (German)

Currently translated at 100.0% (274 of 274 strings)

Added translation using Weblate (Italian)

Added translation using Weblate (German)

Added translation using Weblate (French)

Translated using Weblate (Finnish)

Currently translated at 99.6% (270 of 271 strings)

Translated using Weblate (French)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (German)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Finnish)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (French)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (German)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Belarusian)

Currently translated at 98.8% (266 of 269 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/it/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-16 09:46:16 +03:00
Luiz-bro
22d48fce8f Added translation using Weblate (Portuguese (Brazil))
Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (268 of 269 strings)

Co-authored-by: Luiz-bro <luiznneto1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2022-04-16 09:46:16 +03:00
kuragehime
6b2666c701 Translated using Weblate (Japanese)
Currently translated at 100.0% (8 of 8 strings)

Added translation using Weblate (Japanese)

Translated using Weblate (Japanese)

Currently translated at 100.0% (274 of 274 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (269 of 269 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ja/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-16 09:46:16 +03:00
Oğuz Ersen
414f438762 Translated using Weblate (Turkish)
Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (274 of 274 strings)

Added translation using Weblate (Turkish)

Translated using Weblate (Turkish)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (269 of 269 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
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
2022-04-16 09:46:16 +03:00
Aliaksiej Razumaŭ
54f60040b5 Translated using Weblate (Belarusian)
Currently translated at 100.0% (270 of 270 strings)

Co-authored-by: Aliaksiej Razumaŭ <belarusaed@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
2022-04-16 09:46:16 +03:00
b0515033da Increase version 2022-04-16 09:40:50 +03:00
d37eb07301 Fix startForeground for DownloadService 2022-04-16 09:19:10 +03:00
Sergio Varela
c2fa27712c Added translation using Weblate (Basque)
Added translation using Weblate (Basque)

Co-authored-by: Sergio Varela <sergitroll9@gmail.com>
2022-04-15 12:57:32 +02:00
lowak
10a2589c10 Translated using Weblate (Swedish)
Currently translated at 100.0% (274 of 274 strings)

Translated using Weblate (Swedish)

Currently translated at 41.6% (114 of 274 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (8 of 8 strings)

Added translation using Weblate (Swedish)

Added translation using Weblate (Swedish)

Co-authored-by: lowak <lowak@pm.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/sv/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sv/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-15 12:57:31 +02:00
Luiz-bro
05eb96e7c0 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (274 of 274 strings)

Co-authored-by: Luiz-bro <luiznneto1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-15 12:57:31 +02:00
Santiago José Gutiérrez Llanos
15a08ad6ae Translated using Weblate (Spanish)
Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (274 of 274 strings)

Co-authored-by: Santiago José Gutiérrez Llanos <gutierrezapata17@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-15 12:57:31 +02:00
Allan Nordhøy
3e437c2ecb Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (8 of 8 strings)

Added translation using Weblate (Norwegian Bokmål)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/nb_NO/
Translation: Kotatsu/plurals
2022-04-15 12:57:30 +02:00
J. Lavoie
fea667b87c Translated using Weblate (Italian)
Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (German)

Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (French)

Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Finnish)

Currently translated at 99.2% (272 of 274 strings)

Translated using Weblate (French)

Currently translated at 100.0% (274 of 274 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (274 of 274 strings)

Translated using Weblate (German)

Currently translated at 100.0% (274 of 274 strings)

Added translation using Weblate (Italian)

Added translation using Weblate (German)

Added translation using Weblate (French)

Translated using Weblate (Finnish)

Currently translated at 99.6% (270 of 271 strings)

Translated using Weblate (French)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (German)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Finnish)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (French)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (German)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Belarusian)

Currently translated at 98.8% (266 of 269 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/it/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-15 12:57:30 +02:00
Luiz-bro
93eaaac084 Added translation using Weblate (Portuguese (Brazil))
Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (268 of 269 strings)

Co-authored-by: Luiz-bro <luiznneto1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2022-04-15 12:57:29 +02:00
kuragehime
a60df582a2 Translated using Weblate (Japanese)
Currently translated at 100.0% (8 of 8 strings)

Added translation using Weblate (Japanese)

Translated using Weblate (Japanese)

Currently translated at 100.0% (274 of 274 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (269 of 269 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ja/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-15 12:57:29 +02:00
Oğuz Ersen
aec1d4e0d6 Translated using Weblate (Turkish)
Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (274 of 274 strings)

Added translation using Weblate (Turkish)

Translated using Weblate (Turkish)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (269 of 269 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
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
2022-04-15 12:57:28 +02:00
Aliaksiej Razumaŭ
507f2e883c Translated using Weblate (Belarusian)
Currently translated at 100.0% (270 of 270 strings)

Co-authored-by: Aliaksiej Razumaŭ <belarusaed@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
2022-04-15 12:57:28 +02:00
5e82c75893 Cleanup extensions 2022-04-15 09:09:40 +03:00
9c9a389aa5 Rethrow CancellationException 2022-04-14 08:07:57 +03:00
1b3af70690 Add WindowInsetHolder view 2022-04-14 08:06:16 +03:00
2e17efe82b Update parsers 2022-04-11 18:43:52 +03:00
5bed854b9c Refactor entity mapping 2022-04-10 11:00:05 +03:00
7262b403f0 Hide reading fab if history is empty 2022-04-10 10:25:11 +03:00
a6fcbefc7b Update strings 2022-04-09 18:47:25 +03:00
7f9ea0efa0 Merge branch 'feature/multiselect' into devel 2022-04-09 18:39:25 +03:00
934861322e Migrate to ExtendedFloatingActionButton 2022-04-09 18:35:07 +03:00
e008fbab9b Merge branch 'devel' into feature/multiselect 2022-04-09 08:29:57 +03:00
2cd9ea19fd Update dependencies 2022-04-09 08:28:01 +03:00
699a249620 Merge branch 'documentation-update' of https://github.com/grrrrr/Kotatsu into devel 2022-04-09 07:35:16 +03:00
6c87d5b0bc Add check to avoid TransactionTooLargeException 2022-04-08 18:15:04 +03:00
c92bdae842 Add tags blacklist option for suggestions 2022-04-08 14:56:45 +03:00
6ca9608a80 Remove CurlLoggingInterceptor 2022-04-07 17:23:59 +03:00
8f9c0cbff1 Fix tags suggestion 2022-04-07 17:20:02 +03:00
cc6b114e4d Improve suggestions worker 2022-04-07 17:04:11 +03:00
grrrrr
3d5c2123d4 Update full_description.txt
- Remove HTML code so displaying on sites such as f-droid does not create a lot of wasted space
2022-04-06 19:13:43 +00:00
grrrrr
36b4e16b7c Update full_description.txt
- Remote HTML code so displaying on sites such as f-droid does not create a lot of wasted space
- add additional features taken from updated README.md
2022-04-06 19:12:15 +00:00
grrrrr
3ebd074e93 Update README.md
change feature "localized in" to "available in
2022-04-06 19:10:23 +00:00
grrrrr
e9b2b545a4 Update README.md
- Add additional features (password protection and localization) to list.
- Add details on how to contribute to translation
2022-04-06 19:05:33 +00:00
cca6d5fa04 Migrate to expedited jobs 2022-04-06 18:38:37 +03:00
36a7a3ebbc Fix DownloadService foreground notification #50 2022-04-06 17:24:10 +03:00
48ec9a1ea9 Merge branch 'feature/settings' into devel 2022-04-06 17:23:08 +03:00
76a9a0d1ab ActionMode selection in manga lists 2022-04-06 17:21:09 +03:00
f2175b40c0 Improve android AutoBackup support 2022-04-05 07:40:00 +03:00
85b992ca32 Remove SimpleSettingsActivity 2022-04-04 10:02:20 +03:00
41fb351fe0 Use master-detals pattern for settings 2022-04-04 09:41:57 +03:00
202 changed files with 3273 additions and 1855 deletions

2
.idea/gradle.xml generated
View File

@@ -7,7 +7,7 @@
<option name="testRunner" value="GRADLE" /> <option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" /> <option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="Embedded JDK" /> <option name="gradleJvm" value="Android Studio default JDK" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />

View File

@@ -25,6 +25,8 @@ Download APK from Github Releases:
* Tablet-optimized material design UI * Tablet-optimized material design UI
* Standard and Webtoon-optimized reader * Standard and Webtoon-optimized reader
* Notifications about new chapters with updates feed * Notifications about new chapters with updates feed
* Available in multiple languages
* Password protect access to the app
### Screenshots ### Screenshots
@@ -35,6 +37,14 @@ Download APK from Github Releases:
| ![](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/1.png) | ![](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/2.png) | | ![](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/1.png) | ![](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/2.png) |
|-----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------| |-----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
### Localization
<a href="https://hosted.weblate.org/engage/kotatsu/">
<img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status" />
</a>
Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages, please head over to the Weblate <a href="https://hosted.weblate.org/engage/kotatsu/">project page</a>
### License ### License
[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html) [![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html)

View File

@@ -14,8 +14,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 32 targetSdkVersion 32
versionCode 400 versionCode 402
versionName '3.0' versionName '3.1.1'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -65,12 +65,12 @@ android {
} }
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation('com.github.nv95:kotatsu-parsers:3ea7e92e64') { implementation('com.github.nv95:kotatsu-parsers:8e23a7fcd4') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.activity:activity-ktx:1.4.0' implementation 'androidx.activity:activity-ktx:1.4.0'
@@ -100,7 +100,7 @@ dependencies {
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 'io.insert-koin:koin-android:3.1.5' implementation 'io.insert-koin:koin-android:3.1.6'
implementation 'io.coil-kt:coil-base:1.4.0' implementation 'io.coil-kt:coil-base:1.4.0'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
@@ -108,7 +108,7 @@ dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1'
testImplementation 'io.insert-koin:koin-test-junit4:3.1.5' testImplementation 'io.insert-koin:koin-test-junit4:3.1.5'
androidTestImplementation 'androidx.test:runner:1.4.0' androidTestImplementation 'androidx.test:runner:1.4.0'

View File

@@ -8,23 +8,23 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application <application
android:name="org.koitharu.kotatsu.KotatsuApp" android:name="org.koitharu.kotatsu.KotatsuApp"
android:allowBackup="true" android:allowBackup="true"
android:fullBackupContent="@xml/backup_descriptor" android:backupAgent="org.koitharu.kotatsu.settings.backup.AppBackupAgent"
android:dataExtractionRules="@xml/backup_rules"
android:fullBackupContent="@xml/backup_content"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Kotatsu" android:theme="@style/Theme.Kotatsu"
android:networkSecurityConfig="@xml/network_security_config"
tools:ignore="UnusedAttribute"> tools:ignore="UnusedAttribute">
<activity <activity
android:name="org.koitharu.kotatsu.main.ui.MainActivity" android:name="org.koitharu.kotatsu.main.ui.MainActivity"
android:exported="true"> android:exported="true">
@@ -58,15 +58,6 @@
<activity <activity
android:name="org.koitharu.kotatsu.settings.SettingsActivity" android:name="org.koitharu.kotatsu.settings.SettingsActivity"
android:label="@string/settings" /> android:label="@string/settings" />
<activity
android:name="org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity"
android:exported="true"
android:label="@string/settings">
<intent-filter>
<action android:name="android.intent.action.MANAGE_NETWORK_USAGE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity <activity
android:name="org.koitharu.kotatsu.browser.BrowserActivity" android:name="org.koitharu.kotatsu.browser.BrowserActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
@@ -104,6 +95,7 @@
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity" android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
android:launchMode="singleTop"
android:label="@string/downloads" /> android:label="@string/downloads" />
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity"/> <activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity"/>

View File

@@ -2,22 +2,19 @@ package org.koitharu.kotatsu.base.domain
import androidx.room.withTransaction import androidx.room.withTransaction
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.mapToSet
class MangaDataRepository(private val db: MangaDatabase) { class MangaDataRepository(private val db: MangaDatabase) {
suspend fun savePreferences(manga: Manga, mode: ReaderMode) { suspend fun savePreferences(manga: Manga, mode: ReaderMode) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag) val tags = manga.tags.toEntities()
db.withTransaction { db.withTransaction {
db.tagsDao.upsert(tags) db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags) db.mangaDao.upsert(manga.toEntity(), tags)
db.preferencesDao.upsert( db.preferencesDao.upsert(
MangaPrefsEntity( MangaPrefsEntity(
mangaId = manga.id, mangaId = manga.id,
@@ -37,21 +34,19 @@ class MangaDataRepository(private val db: MangaDatabase) {
suspend fun resolveIntent(intent: MangaIntent): Manga? = when { suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
intent.manga != null -> intent.manga intent.manga != null -> intent.manga
intent.mangaId != 0L -> db.mangaDao.find(intent.mangaId)?.toManga() intent.mangaId != 0L -> findMangaById(intent.mangaId)
else -> null // TODO resolve uri else -> null // TODO resolve uri
} }
suspend fun storeManga(manga: Manga) { suspend fun storeManga(manga: Manga) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag) val tags = manga.tags.toEntities()
db.withTransaction { db.withTransaction {
db.tagsDao.upsert(tags) db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags) db.mangaDao.upsert(manga.toEntity(), tags)
} }
} }
suspend fun findTags(source: MangaSource): Set<MangaTag> { suspend fun findTags(source: MangaSource): Set<MangaTag> {
return db.tagsDao.findTags(source.name).mapToSet { return db.tagsDao.findTags(source.name).toMangaTags()
it.toMangaTag()
}
} }
} }

View File

@@ -14,7 +14,7 @@ import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.medianOrNull import org.koitharu.kotatsu.parsers.util.medianOrNull
import java.io.InputStream import java.io.InputStream
import java.util.zip.ZipFile import java.util.zip.ZipFile

View File

@@ -7,6 +7,7 @@ import android.view.KeyEvent
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.ActionBarContextView import androidx.appcompat.widget.ActionBarContextView
@@ -20,11 +21,13 @@ import androidx.viewbinding.ViewBinding
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(),
WindowInsetsDelegate.WindowInsetsListener { WindowInsetsDelegate.WindowInsetsListener {
protected lateinit var binding: B protected lateinit var binding: B
@@ -36,6 +39,8 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
@Suppress("LeakingThis") @Suppress("LeakingThis")
protected val insetsDelegate = WindowInsetsDelegate(this) protected val insetsDelegate = WindowInsetsDelegate(this)
val actionModeDelegate = ActionModeDelegate()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
val settings = get<AppSettings>() val settings = get<AppSettings>()
when { when {
@@ -90,8 +95,10 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
return isNight && get<AppSettings>().isAmoledTheme return isNight && get<AppSettings>().isAmoledTheme
} }
@CallSuper
override fun onSupportActionModeStarted(mode: ActionMode) { override fun onSupportActionModeStarted(mode: ActionMode) {
super.onSupportActionModeStarted(mode) super.onSupportActionModeStarted(mode)
actionModeDelegate.onSupportActionModeStarted(mode)
val insets = ViewCompat.getRootWindowInsets(binding.root) val insets = ViewCompat.getRootWindowInsets(binding.root)
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return ?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
val view = findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar) val view = findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar)
@@ -100,6 +107,12 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
} }
} }
@CallSuper
override fun onSupportActionModeFinished(mode: ActionMode) {
super.onSupportActionModeFinished(mode)
actionModeDelegate.onSupportActionModeFinished(mode)
}
override fun onBackPressed() { override fun onBackPressed() {
if ( // https://issuetracker.google.com/issues/139738913 if ( // https://issuetracker.google.com/issues/139738913
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q && Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&

View File

@@ -6,10 +6,12 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
abstract class BaseFragment<B : ViewBinding> : Fragment(), abstract class BaseFragment<B : ViewBinding> :
Fragment(),
WindowInsetsDelegate.WindowInsetsListener { WindowInsetsDelegate.WindowInsetsListener {
private var viewBinding: B? = null private var viewBinding: B? = null
@@ -23,6 +25,9 @@ abstract class BaseFragment<B : ViewBinding> : Fragment(),
@Suppress("LeakingThis") @Suppress("LeakingThis")
protected val insetsDelegate = WindowInsetsDelegate(this) protected val insetsDelegate = WindowInsetsDelegate(this)
protected val actionModeDelegate: ActionModeDelegate
get() = (requireActivity() as BaseActivity<*>).actionModeDelegate
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,

View File

@@ -6,14 +6,18 @@ import androidx.annotation.CallSuper
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.SettingsHeadersFragment
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : PreferenceFragmentCompat(), abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(),
WindowInsetsDelegate.WindowInsetsListener, WindowInsetsDelegate.WindowInsetsListener,
RecyclerViewOwner { RecyclerViewOwner {
@@ -39,16 +43,20 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : Pre
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (titleId != 0) { if (titleId != 0) {
activity?.setTitle(titleId) setTitle(getString(titleId))
} }
} }
@CallSuper @CallSuper
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
listView.updatePadding( listView.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom bottom = insets.bottom
) )
} }
@Suppress("UsePropertyAccessSyntax")
protected fun setTitle(title: CharSequence) {
(parentFragment as? SettingsHeadersFragment)?.setTitle(title)
?: activity?.setTitle(title)
}
} }

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.base.ui.dialog
import android.content.Context import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.BaseAdapter import android.widget.BaseAdapter
@@ -12,7 +13,6 @@ import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemStorageBinding import org.koitharu.kotatsu.databinding.ItemStorageBinding
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.utils.ext.inflate
import java.io.File import java.io.File
class StorageSelectDialog private constructor(private val delegate: AlertDialog) : class StorageSelectDialog private constructor(private val delegate: AlertDialog) :
@@ -66,7 +66,7 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
val volumes = getAvailableVolumes(storageManager) val volumes = getAvailableVolumes(storageManager)
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: parent.inflate(R.layout.item_storage) val view = convertView ?: LayoutInflater.from(parent.context).inflate(R.layout.item_storage, parent, false)
val binding = (view.tag as? ItemStorageBinding) ?: ItemStorageBinding.bind(view).also { val binding = (view.tag as? ItemStorageBinding) ?: ItemStorageBinding.bind(view).also {
view.tag = it view.tag = it
} }

View File

@@ -0,0 +1,111 @@
package org.koitharu.kotatsu.base.ui.list.decor
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.RectF
import android.view.View
import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID
abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
private val bounds = Rect()
private val boundsF = RectF()
private val selection = HashSet<Long>()
protected var hasBackground: Boolean = true
protected var hasForeground: Boolean = false
protected var isIncludeDecorAndMargins: Boolean = true
val checkedItemsCount: Int
get() = selection.size
val checkedItemsIds: Set<Long>
get() = selection
fun toggleItemChecked(id: Long) {
if (!selection.remove(id)) {
selection.add(id)
}
}
fun setItemIsChecked(id: Long, isChecked: Boolean) {
if (isChecked) {
selection.add(id)
} else {
selection.remove(id)
}
}
fun checkAll(ids: Collection<Long>) {
selection.addAll(ids)
}
fun clearSelection() {
selection.clear()
}
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
if (hasBackground) {
doDraw(canvas, parent, state, false)
} else {
super.onDraw(canvas, parent, state)
}
}
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
if (hasForeground) {
doDraw(canvas, parent, state, true)
} else {
super.onDrawOver(canvas, parent, state)
}
}
private fun doDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State, isOver: Boolean) {
val checkpoint = canvas.save()
if (parent.clipToPadding) {
canvas.clipRect(
parent.paddingLeft, parent.paddingTop, parent.width - parent.paddingRight,
parent.height - parent.paddingBottom
)
}
for (child in parent.children) {
val itemId = getItemId(parent, child)
if (itemId != NO_ID && itemId in selection) {
if (isIncludeDecorAndMargins) {
parent.getDecoratedBoundsWithMargins(child, bounds)
} else {
bounds.set(child.left, child.top, child.right, child.bottom)
}
boundsF.set(bounds)
boundsF.offset(child.translationX, child.translationY)
if (isOver) {
onDrawForeground(canvas, parent, child, boundsF, state)
} else {
onDrawBackground(canvas, parent, child, boundsF, state)
}
}
}
canvas.restoreToCount(checkpoint)
}
protected open fun getItemId(parent: RecyclerView, child: View) = parent.getChildItemId(child)
protected open fun onDrawBackground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) = Unit
protected open fun onDrawForeground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) = Unit
}

View File

@@ -0,0 +1,50 @@
package org.koitharu.kotatsu.base.ui.util
import androidx.appcompat.view.ActionMode
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
class ActionModeDelegate {
private var activeActionMode: ActionMode? = null
private var listeners: MutableList<ActionModeListener>? = null
val isActionModeStarted: Boolean
get() = activeActionMode != null
fun onSupportActionModeStarted(mode: ActionMode) {
activeActionMode = mode
listeners?.forEach { it.onActionModeStarted(mode) }
}
fun onSupportActionModeFinished(mode: ActionMode) {
activeActionMode = null
listeners?.forEach { it.onActionModeFinished(mode) }
}
fun addListener(listener: ActionModeListener) {
if (listeners == null) {
listeners = ArrayList()
}
checkNotNull(listeners).add(listener)
}
fun removeListener(listener: ActionModeListener) {
listeners?.remove(listener)
}
fun addListener(listener: ActionModeListener, owner: LifecycleOwner) {
addListener(listener)
owner.lifecycle.addObserver(ListenerLifecycleObserver(listener))
}
private inner class ListenerLifecycleObserver(
private val listener: ActionModeListener,
) : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
removeListener(listener)
}
}
}

View File

@@ -0,0 +1,10 @@
package org.koitharu.kotatsu.base.ui.util
import androidx.appcompat.view.ActionMode
interface ActionModeListener {
fun onActionModeStarted(mode: ActionMode)
fun onActionModeFinished(mode: ActionMode)
}

View File

@@ -0,0 +1,48 @@
package org.koitharu.kotatsu.base.ui.util
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior
import androidx.core.view.ViewCompat
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
class ShrinkOnScrollBehavior : Behavior<ExtendedFloatingActionButton> {
@Suppress("unused") constructor() : super()
@Suppress("unused") constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
override fun onStartNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: ExtendedFloatingActionButton,
directTargetChild: View,
target: View,
axes: Int,
type: Int
): Boolean {
return axes == ViewCompat.SCROLL_AXIS_VERTICAL
}
override fun onNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: ExtendedFloatingActionButton,
target: View,
dxConsumed: Int,
dyConsumed: Int,
dxUnconsumed: Int,
dyUnconsumed: Int,
type: Int,
consumed: IntArray
) {
if (dyConsumed > 0) {
if (child.isExtended) {
child.shrink()
}
} else if (dyConsumed < 0) {
if (!child.isExtended) {
child.extend()
}
}
}
}

View File

@@ -13,12 +13,12 @@ import android.graphics.drawable.shapes.RectShape
import android.util.AttributeSet import android.util.AttributeSet
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatCheckedTextView import androidx.appcompat.widget.AppCompatCheckedTextView
import androidx.core.content.res.use
import androidx.core.content.withStyledAttributes import androidx.core.content.withStyledAttributes
import com.google.android.material.ripple.RippleUtils import com.google.android.material.ripple.RippleUtils
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.shape.ShapeAppearanceModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.getThemeColorStateList
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
class ListItemTextView @JvmOverloads constructor( class ListItemTextView @JvmOverloads constructor(
@@ -119,8 +119,7 @@ class ListItemTextView @JvmOverloads constructor(
} }
private fun getRippleColorFallback(context: Context): ColorStateList { private fun getRippleColorFallback(context: Context): ColorStateList {
return context.obtainStyledAttributes(intArrayOf(android.R.attr.colorControlHighlight)).use { return context.getThemeColorStateList(android.R.attr.colorControlHighlight)
it.getColorStateList(0) ?: ColorStateList.valueOf(Color.TRANSPARENT)
} ?: ColorStateList.valueOf(Color.TRANSPARENT)
} }
} }

View File

@@ -0,0 +1,72 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
import android.view.View
import android.view.WindowInsets
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.annotation.AttrRes
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.WindowInsetsCompat
class WindowInsetHolder @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
private var desiredHeight = 0
private var desiredWidth = 0
@SuppressLint("RtlHardcoded")
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
val barsInsets = WindowInsetsCompat.toWindowInsetsCompat(insets, this)
.getInsets(WindowInsetsCompat.Type.systemBars())
val gravity = getLayoutGravity()
val newWidth = when (gravity and Gravity.HORIZONTAL_GRAVITY_MASK) {
Gravity.LEFT -> barsInsets.left
Gravity.RIGHT -> barsInsets.right
else -> 0
}
val newHeight = when (gravity and Gravity.VERTICAL_GRAVITY_MASK) {
Gravity.TOP -> barsInsets.top
Gravity.BOTTOM -> barsInsets.bottom
else -> 0
}
if (newWidth != desiredWidth || newHeight != desiredHeight) {
desiredWidth = newWidth
desiredHeight = newHeight
requestLayout()
}
return super.dispatchApplyWindowInsets(insets)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
super.onMeasure(
if (desiredWidth == 0 || widthMode == MeasureSpec.EXACTLY) {
widthMeasureSpec
} else {
MeasureSpec.makeMeasureSpec(desiredWidth, widthMode)
},
if (desiredHeight == 0 || heightMode == MeasureSpec.EXACTLY) {
heightMeasureSpec
} else {
MeasureSpec.makeMeasureSpec(desiredHeight, heightMode)
},
)
}
private fun getLayoutGravity(): Int {
return when (val lp = layoutParams) {
is FrameLayout.LayoutParams -> lp.gravity
is LinearLayout.LayoutParams -> lp.gravity
is CoordinatorLayout.LayoutParams -> lp.gravity
else -> Gravity.NO_GRAVITY
}
}
}

View File

@@ -1,28 +1,9 @@
package org.koitharu.kotatsu.core.db package org.koitharu.kotatsu.core.db
import androidx.room.Room
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.core.db.migrations.*
val databaseModule val databaseModule
get() = module { get() = module {
single { single { MangaDatabase.create(androidContext()) }
Room.databaseBuilder(
androidContext(),
MangaDatabase::class.java,
"kotatsu-db"
).addMigrations(
Migration1To2(),
Migration2To3(),
Migration3To4(),
Migration4To5(),
Migration5To6(),
Migration6To7(),
Migration7To8(),
Migration8To9(),
).addCallback(
DatabasePrePopulateCallback(androidContext().resources)
).build()
}
} }

View File

@@ -1,9 +1,12 @@
package org.koitharu.kotatsu.core.db package org.koitharu.kotatsu.core.db
import android.content.Context
import androidx.room.Database import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import org.koitharu.kotatsu.core.db.dao.* import org.koitharu.kotatsu.core.db.dao.*
import org.koitharu.kotatsu.core.db.entity.* import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.db.migrations.*
import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
@@ -40,4 +43,24 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val trackLogsDao: TrackLogsDao abstract val trackLogsDao: TrackLogsDao
abstract val suggestionDao: SuggestionDao abstract val suggestionDao: SuggestionDao
companion object {
fun create(context: Context): MangaDatabase = Room.databaseBuilder(
context,
MangaDatabase::class.java,
"kotatsu-db"
).addMigrations(
Migration1To2(),
Migration2To3(),
Migration3To4(),
Migration4To5(),
Migration5To6(),
Migration6To7(),
Migration7To8(),
Migration8To9(),
).addCallback(
DatabasePrePopulateCallback(context.resources)
).build()
}
} }

View File

@@ -12,7 +12,7 @@ abstract class TagsDao {
@Query( @Query(
"""SELECT tags.* FROM tags """SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
GROUP BY manga_tags.tag_id GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC ORDER BY COUNT(manga_id) DESC
LIMIT :limit""" LIMIT :limit"""
) )
@@ -22,7 +22,7 @@ abstract class TagsDao {
"""SELECT tags.* FROM tags """SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE tags.source = :source WHERE tags.source = :source
GROUP BY manga_tags.tag_id GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC ORDER BY COUNT(manga_id) DESC
LIMIT :limit""" LIMIT :limit"""
) )
@@ -32,7 +32,7 @@ abstract class TagsDao {
"""SELECT tags.* FROM tags """SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE tags.source = :source AND title LIKE :query WHERE tags.source = :source AND title LIKE :query
GROUP BY manga_tags.tag_id GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC ORDER BY COUNT(manga_id) DESC
LIMIT :limit""" LIMIT :limit"""
) )
@@ -42,7 +42,7 @@ abstract class TagsDao {
"""SELECT tags.* FROM tags """SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE title LIKE :query WHERE title LIKE :query
GROUP BY manga_tags.tag_id GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC ORDER BY COUNT(manga_id) DESC
LIMIT :limit""" LIMIT :limit"""
) )

View File

@@ -0,0 +1,76 @@
package org.koitharu.kotatsu.core.db.entity
import java.util.*
import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
// Entity to model
fun TagEntity.toMangaTag() = MangaTag(
key = this.key,
title = this.title.toTitleCase(),
source = MangaSource.valueOf(this.source),
)
fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
id = this.id,
title = this.title,
altTitle = this.altTitle,
state = this.state?.let { MangaState.valueOf(it) },
rating = this.rating,
isNsfw = this.isNsfw,
url = this.url,
publicUrl = this.publicUrl,
coverUrl = this.coverUrl,
largeCoverUrl = this.largeCoverUrl,
author = this.author,
source = MangaSource.valueOf(this.source),
tags = tags
)
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
fun TrackLogWithManga.toTrackingLogItem() = TrackingLogItem(
id = trackLog.id,
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
manga = manga.toManga(tags.toMangaTags()),
createdAt = Date(trackLog.createdAt)
)
// Model to entity
fun Manga.toEntity() = MangaEntity(
id = id,
url = url,
publicUrl = publicUrl,
source = source.name,
largeCoverUrl = largeCoverUrl,
coverUrl = coverUrl,
altTitle = altTitle,
rating = rating,
isNsfw = isNsfw,
state = state?.name,
title = title,
author = author,
)
fun MangaTag.toEntity() = TagEntity(
title = title,
key = key,
source = source.name,
id = "${key}_${source.name}".longHashCode()
)
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
// Other
@Suppress("FunctionName")
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
SortOrder.valueOf(name)
}.getOrDefault(fallback)

View File

@@ -3,10 +3,6 @@ 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.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
@Entity(tableName = "manga") @Entity(tableName = "manga")
class MangaEntity( class MangaEntity(
@@ -16,46 +12,11 @@ class MangaEntity(
@ColumnInfo(name = "alt_title") val altTitle: String?, @ColumnInfo(name = "alt_title") val altTitle: String?,
@ColumnInfo(name = "url") val url: String, @ColumnInfo(name = "url") val url: String,
@ColumnInfo(name = "public_url") val publicUrl: String, @ColumnInfo(name = "public_url") val publicUrl: String,
@ColumnInfo(name = "rating") val rating: Float, //normalized value [0..1] or -1 @ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
@ColumnInfo(name = "nsfw") val isNsfw: Boolean, @ColumnInfo(name = "nsfw") val isNsfw: Boolean,
@ColumnInfo(name = "cover_url") val coverUrl: String, @ColumnInfo(name = "cover_url") val coverUrl: String,
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?, @ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
@ColumnInfo(name = "state") val state: String?, @ColumnInfo(name = "state") val state: String?,
@ColumnInfo(name = "author") val author: String?, @ColumnInfo(name = "author") val author: String?,
@ColumnInfo(name = "source") val source: String @ColumnInfo(name = "source") val source: String
) { )
fun toManga(tags: Set<MangaTag> = emptySet()) = Manga(
id = this.id,
title = this.title,
altTitle = this.altTitle,
state = this.state?.let { MangaState.valueOf(it) },
rating = this.rating,
isNsfw = this.isNsfw,
url = this.url,
publicUrl = this.publicUrl,
coverUrl = this.coverUrl,
largeCoverUrl = this.largeCoverUrl,
author = this.author,
source = MangaSource.valueOf(this.source),
tags = tags
)
companion object {
fun from(manga: Manga) = MangaEntity(
id = manga.id,
url = manga.url,
publicUrl = manga.publicUrl,
source = manga.source.name,
largeCoverUrl = manga.largeCoverUrl,
coverUrl = manga.coverUrl,
altTitle = manga.altTitle,
rating = manga.rating,
isNsfw = manga.isNsfw,
state = manga.state?.name,
title = manga.title,
author = manga.author
)
}
}

View File

@@ -6,13 +6,15 @@ import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@Entity( @Entity(
tableName = "preferences", foreignKeys = [ tableName = "preferences",
foreignKeys = [
ForeignKey( ForeignKey(
entity = MangaEntity::class, entity = MangaEntity::class,
parentColumns = ["manga_id"], parentColumns = ["manga_id"],
childColumns = ["manga_id"], childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE onDelete = ForeignKey.CASCADE
)] )
]
) )
class MangaPrefsEntity( class MangaPrefsEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)

View File

@@ -5,7 +5,8 @@ import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
@Entity( @Entity(
tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"], foreignKeys = [ tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"],
foreignKeys = [
ForeignKey( ForeignKey(
entity = MangaEntity::class, entity = MangaEntity::class,
parentColumns = ["manga_id"], parentColumns = ["manga_id"],

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.Embedded import androidx.room.Embedded
import androidx.room.Junction import androidx.room.Junction
import androidx.room.Relation import androidx.room.Relation
import org.koitharu.kotatsu.utils.ext.mapToSet
class MangaWithTags( class MangaWithTags(
@Embedded val manga: MangaEntity, @Embedded val manga: MangaEntity,
@@ -12,10 +11,5 @@ class MangaWithTags(
entityColumn = "tag_id", entityColumn = "tag_id",
associateBy = Junction(MangaTagsEntity::class) associateBy = Junction(MangaTagsEntity::class)
) )
val tags: List<TagEntity> val tags: List<TagEntity>,
) { )
fun toManga() = manga.toManga(tags.mapToSet {
it.toMangaTag()
})
}

View File

@@ -3,10 +3,6 @@ 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.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.parsers.util.toTitleCase
@Entity(tableName = "tags") @Entity(tableName = "tags")
class TagEntity( class TagEntity(
@@ -15,21 +11,4 @@ class TagEntity(
@ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "key") val key: String, @ColumnInfo(name = "key") val key: String,
@ColumnInfo(name = "source") val source: String @ColumnInfo(name = "source") val source: String
) { )
fun toMangaTag() = MangaTag(
key = this.key,
title = this.title.toTitleCase(),
source = MangaSource.valueOf(this.source)
)
companion object {
fun fromMangaTag(tag: MangaTag) = TagEntity(
title = tag.title,
key = tag.key,
source = tag.source.name,
id = "${tag.key}_${tag.source.name}".longHashCode()
)
}
}

View File

@@ -6,7 +6,8 @@ import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@Entity( @Entity(
tableName = "tracks", foreignKeys = [ tableName = "tracks",
foreignKeys = [
ForeignKey( ForeignKey(
entity = MangaEntity::class, entity = MangaEntity::class,
parentColumns = ["manga_id"], parentColumns = ["manga_id"],

View File

@@ -6,7 +6,8 @@ import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@Entity( @Entity(
tableName = "track_logs", foreignKeys = [ tableName = "track_logs",
foreignKeys = [
ForeignKey( ForeignKey(
entity = MangaEntity::class, entity = MangaEntity::class,
parentColumns = ["manga_id"], parentColumns = ["manga_id"],
@@ -20,5 +21,5 @@ class TrackLogEntity(
@ColumnInfo(name = "id") val id: Long = 0L, @ColumnInfo(name = "id") val id: Long = 0L,
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "chapters") val chapters: String, @ColumnInfo(name = "chapters") val chapters: String,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis() @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
) )

View File

@@ -3,9 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.Embedded import androidx.room.Embedded
import androidx.room.Junction import androidx.room.Junction
import androidx.room.Relation import androidx.room.Relation
import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.utils.ext.mapToSet
import java.util.*
class TrackLogWithManga( class TrackLogWithManga(
@Embedded val trackLog: TrackLogEntity, @Embedded val trackLog: TrackLogEntity,
@@ -19,13 +16,5 @@ class TrackLogWithManga(
entityColumn = "tag_id", entityColumn = "tag_id",
associateBy = Junction(MangaTagsEntity::class) associateBy = Junction(MangaTagsEntity::class)
) )
val tags: List<TagEntity> val tags: List<TagEntity>,
) { )
fun toTrackingLogItem() = TrackingLogItem(
id = trackLog.id,
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
manga = manga.toManga(tags.mapToSet { x -> x.toMangaTag() }),
createdAt = Date(trackLog.createdAt)
)
}

View File

@@ -1,25 +1,6 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.core.model
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet
fun Manga.withoutChapters() = if (chapters.isNullOrEmpty()) { fun Collection<Manga>.ids() = mapToSet { it.id }
this
} else {
Manga(
id = id,
title = title,
altTitle = altTitle,
url = url,
publicUrl = publicUrl,
rating = rating,
isNsfw = isNsfw,
coverUrl = coverUrl,
tags = tags,
state = state,
author = author,
largeCoverUrl = largeCoverUrl,
description = description,
chapters = null,
source = source,
)
}

View File

@@ -4,7 +4,7 @@ import android.os.Parcel
import androidx.core.os.ParcelCompat import androidx.core.os.ParcelCompat
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
fun Manga.writeToParcel(out: Parcel, flags: Int) { fun Manga.writeToParcel(out: Parcel, flags: Int, withChapters: Boolean) {
out.writeLong(id) out.writeLong(id)
out.writeString(title) out.writeString(title)
out.writeString(altTitle) out.writeString(altTitle)
@@ -18,7 +18,11 @@ fun Manga.writeToParcel(out: Parcel, flags: Int) {
out.writeParcelable(ParcelableMangaTags(tags), flags) out.writeParcelable(ParcelableMangaTags(tags), flags)
out.writeSerializable(state) out.writeSerializable(state)
out.writeString(author) out.writeString(author)
out.writeParcelable(chapters?.let(::ParcelableMangaChapters), flags) if (withChapters) {
out.writeParcelable(chapters?.let(::ParcelableMangaChapters), flags)
} else {
out.writeString(null)
}
out.writeSerializable(source) out.writeSerializable(source)
} }

View File

@@ -2,24 +2,39 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import android.util.Log
import org.koitharu.kotatsu.BuildConfig
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 * 512 // Assume that 512 kb is safe parcel size
private const val MAX_SAFE_CHAPTERS_COUNT = 40 // this is 100% safe
class ParcelableManga( class ParcelableManga(
val manga: Manga, val manga: Manga,
private val withChapters: Boolean,
) : Parcelable { ) : Parcelable {
constructor(parcel: Parcel) : this(parcel.readManga()) constructor(parcel: Parcel) : this(parcel.readManga(), true)
init {
if (BuildConfig.DEBUG && manga.chapters != null) {
Log.w("ParcelableManga", "Passing manga with chapters as Parcelable is dangerous!")
}
}
override fun writeToParcel(parcel: Parcel, flags: Int) { override fun writeToParcel(parcel: Parcel, flags: Int) {
manga.writeToParcel(parcel, flags) val chapters = manga.chapters
if (!withChapters || chapters == null) {
manga.writeToParcel(parcel, flags, withChapters = false)
return
}
if (chapters.size <= MAX_SAFE_CHAPTERS_COUNT) {
// fast path
manga.writeToParcel(parcel, flags, withChapters = true)
return
}
val tempParcel = Parcel.obtain()
manga.writeToParcel(tempParcel, flags, withChapters = true)
val size = tempParcel.dataSize()
if (size < MAX_SAFE_SIZE) {
parcel.appendFrom(tempParcel, 0, size)
} else {
manga.writeToParcel(parcel, flags, withChapters = false)
}
tempParcel.recycle()
} }
override fun describeContents(): Int { override fun describeContents(): Int {

View File

@@ -1,56 +0,0 @@
package org.koitharu.kotatsu.core.network
import android.util.Log
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import okio.Buffer
import java.io.IOException
import java.nio.charset.StandardCharsets
private const val TAG = "CURL"
class CurlLoggingInterceptor(
private val extraCurlOptions: String? = null,
) : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val request: Request = chain.request()
var compressed = false
val curlCmd = StringBuilder("curl")
if (extraCurlOptions != null) {
curlCmd.append(" ").append(extraCurlOptions)
}
curlCmd.append(" -X ").append(request.method)
val headers = request.headers
var i = 0
val count = headers.size
while (i < count) {
val name = headers.name(i)
val value = headers.value(i)
if ("Accept-Encoding".equals(name, ignoreCase = true) && "gzip".equals(value,
ignoreCase = true)
) {
compressed = true
}
curlCmd.append(" -H " + "\"").append(name).append(": ").append(value).append("\"")
i++
}
val requestBody = request.body
if (requestBody != null) {
val buffer = Buffer()
requestBody.writeTo(buffer)
val contentType = requestBody.contentType()
val charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
curlCmd.append(" --data $'")
.append(buffer.readString(charset).replace("\n", "\\n"))
.append("'")
}
curlCmd.append(if (compressed) " --compressed " else " ").append(request.url)
Log.d(TAG, "╭--- cURL (" + request.url + ")")
Log.d(TAG, curlCmd.toString())
Log.d(TAG, "╰--- (copy and paste the above line to a terminal)")
return chain.proceed(request)
}
}

View File

@@ -5,7 +5,6 @@ import okhttp3.CookieJar
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koin.dsl.bind import org.koin.dsl.bind
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
@@ -22,9 +21,6 @@ val networkModule
cache(get<LocalStorageManager>().createHttpCache()) cache(get<LocalStorageManager>().createHttpCache())
addInterceptor(UserAgentInterceptor()) addInterceptor(UserAgentInterceptor())
addInterceptor(CloudFlareInterceptor()) addInterceptor(CloudFlareInterceptor())
if (BuildConfig.DEBUG) {
addNetworkInterceptor(CurlLoggingInterceptor())
}
}.build() }.build()
} }
single<MangaLoaderContext> { MangaLoaderContextImpl(get(), get(), get()) } single<MangaLoaderContext> { MangaLoaderContextImpl(get(), get(), get()) }

View File

@@ -24,7 +24,7 @@ class ShortcutsRepository(
private val context: Context, private val context: Context,
private val coil: ImageLoader, private val coil: ImageLoader,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val mangaRepository: MangaDataRepository private val mangaRepository: MangaDataRepository,
) { ) {
private val iconSize by lazy { private val iconSize by lazy {

View File

@@ -11,6 +11,10 @@ import androidx.collection.arraySetOf
import androidx.core.content.edit import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
@@ -19,10 +23,6 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.getEnumValue import org.koitharu.kotatsu.utils.ext.getEnumValue
import org.koitharu.kotatsu.utils.ext.putEnumValue import org.koitharu.kotatsu.utils.ext.putEnumValue
import org.koitharu.kotatsu.utils.ext.toUriOrNull import org.koitharu.kotatsu.utils.ext.toUriOrNull
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
class AppSettings(context: Context) { class AppSettings(context: Context) {
@@ -165,6 +165,18 @@ class AppSettings(context: Context) {
else -> SimpleDateFormat(format, Locale.getDefault()) else -> SimpleDateFormat(format, Locale.getDefault())
} }
fun getSuggestionsTagsBlacklistRegex(): Regex? {
val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',')
if (string.isNullOrEmpty()) {
return null
}
val tags = string.split(',')
val regex = tags.joinToString(prefix = "(", separator = "|", postfix = ")") { tag ->
Regex.escape(tag.trim())
}
return Regex(regex, RegexOption.IGNORE_CASE)
}
fun getMangaSources(includeHidden: Boolean): List<MangaSource> { fun getMangaSources(includeHidden: Boolean): List<MangaSource> {
val list = MangaSource.values().toMutableList() val list = MangaSource.values().toMutableList()
list.remove(MangaSource.LOCAL) list.remove(MangaSource.LOCAL)
@@ -247,17 +259,16 @@ class AppSettings(context: Context) {
const val KEY_PAGES_PRELOAD = "pages_preload" const val KEY_PAGES_PRELOAD = "pages_preload"
const val KEY_SUGGESTIONS = "suggestions" const val KEY_SUGGESTIONS = "suggestions"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw" const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source" const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_UPDATE_AUTO = "app_update_auto" const val KEY_APP_UPDATE_AUTO = "app_update_auto"
const val KEY_APP_TRANSLATION = "about_app_translation" const val KEY_APP_TRANSLATION = "about_app_translation"
const val KEY_APP_GRATITUDES = "about_gratitudes"
const val KEY_FEEDBACK_4PDA = "about_feedback_4pda" const val KEY_FEEDBACK_4PDA = "about_feedback_4pda"
const val KEY_FEEDBACK_DISCORD = "about_feedback_discord" const val KEY_FEEDBACK_DISCORD = "about_feedback_discord"
const val KEY_FEEDBACK_GITHUB = "about_feedback_github" const val KEY_FEEDBACK_GITHUB = "about_feedback_github"
const val KEY_SUPPORT_DEVELOPER = "about_support_developer"
private const val NETWORK_NEVER = 0 private const val NETWORK_NEVER = 0
private const val NETWORK_ALWAYS = 1 private const val NETWORK_ALWAYS = 1

View File

@@ -178,9 +178,7 @@ class ChaptersFragment :
override fun onNothingSelected(parent: AdapterView<*>?) = Unit override fun onNothingSelected(parent: AdapterView<*>?) = Unit
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
val manga = viewModel.manga.value
mode.menuInflater.inflate(R.menu.mode_chapters, menu) mode.menuInflater.inflate(R.menu.mode_chapters, menu)
mode.title = manga?.title
return true return true
} }
@@ -190,12 +188,7 @@ class ChaptersFragment :
menu.findItem(R.id.action_save).isVisible = items.none { x -> menu.findItem(R.id.action_save).isVisible = items.none { x ->
x.chapter.source == MangaSource.LOCAL x.chapter.source == MangaSource.LOCAL
} }
mode.subtitle = resources.getQuantityString( mode.title = items.size.toString()
R.plurals.chapters_from_x,
items.size,
items.size,
chaptersAdapter?.itemCount ?: 0
)
return true return true
} }

View File

@@ -4,7 +4,6 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
@@ -17,6 +16,7 @@ import androidx.appcompat.view.ActionMode
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
@@ -163,7 +163,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
R.id.action_share -> { R.id.action_share -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
if (it.source == MangaSource.LOCAL) { if (it.source == MangaSource.LOCAL) {
ShareHelper(this).shareCbz(Uri.parse(it.url).toFile()) ShareHelper(this).shareCbz(listOf(it.url.toUri().toFile()))
} else { } else {
ShareHelper(this).shareMangaLink(it) ShareHelper(this).shareMangaLink(it)
} }
@@ -332,7 +332,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
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)) .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
} }
fun newIntent(context: Context, mangaId: Long): Intent { fun newIntent(context: Context, mangaId: Long): Intent {

View File

@@ -5,6 +5,7 @@ import android.os.Bundle
import android.text.Spanned import android.text.Spanned
import android.text.method.LinkMovementMethod import android.text.method.LinkMovementMethod
import android.view.* import android.view.*
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.net.toUri import androidx.core.net.toUri
@@ -224,14 +225,16 @@ class DetailsFragment :
if (viewModel.readingHistory.value == null) { if (viewModel.readingHistory.value == null) {
return false return false
} }
v.showPopupMenu(R.menu.popup_read) { val menu = PopupMenu(v.context, v)
when (it.itemId) { menu.inflate(R.menu.popup_read)
menu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_read -> { R.id.action_read -> {
val branch = viewModel.selectedBranchValue val branch = viewModel.selectedBranchValue
startActivity( startActivity(
ReaderActivity.newIntent( ReaderActivity.newIntent(
context = context ?: return@showPopupMenu false, context = context ?: return@setOnMenuItemClickListener false,
manga = viewModel.manga.value ?: return@showPopupMenu false, manga = viewModel.manga.value ?: return@setOnMenuItemClickListener false,
state = viewModel.chapters.value?.firstOrNull { c -> state = viewModel.chapters.value?.firstOrNull { c ->
c.chapter.branch == branch c.chapter.branch == branch
}?.let { c -> }?.let { c ->
@@ -244,6 +247,7 @@ class DetailsFragment :
else -> false else -> false
} }
} }
menu.show()
return true return true
} }
else -> return false else -> return false

View File

@@ -24,11 +24,11 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
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
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.iterator import org.koitharu.kotatsu.utils.ext.iterator
import org.koitharu.kotatsu.utils.ext.mapToSet
import java.io.IOException import java.io.IOException
class DetailsViewModel( class DetailsViewModel(
@@ -191,7 +191,8 @@ class DetailsViewModel(
// find default branch // find default branch
val hist = historyRepository.getOne(manga) val hist = historyRepository.getOne(manga)
selectedBranch.value = if (hist != null) { selectedBranch.value = if (hist != null) {
manga.chapters?.find { it.id == hist.chapterId }?.branch val currentChapter = manga.chapters?.find { it.id == hist.chapterId }
if (currentChapter != null) currentChapter.branch else predictBranch(manga.chapters)
} else { } else {
predictBranch(manga.chapters) predictBranch(manga.chapters)
} }
@@ -203,6 +204,8 @@ class DetailsViewModel(
} else { } else {
localMangaRepository.findSavedManga(manga) localMangaRepository.findSavedManga(manga)
} }
}.onFailure { error ->
if (BuildConfig.DEBUG) error.printStackTrace()
}.getOrNull() }.getOrNull()
} }

View File

@@ -6,7 +6,7 @@ import android.view.ViewGroup
import android.widget.BaseAdapter import android.widget.BaseAdapter
import android.widget.TextView import android.widget.TextView
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.replaceWith import org.koitharu.kotatsu.parsers.util.replaceWith
class BranchesAdapter : BaseAdapter() { class BranchesAdapter : BaseAdapter() {

View File

@@ -2,69 +2,32 @@ package org.koitharu.kotatsu.details.ui.adapter
import android.content.Context import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Rect import android.graphics.RectF
import androidx.core.content.ContextCompat import android.view.View
import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.utils.ext.getThemeColor
import com.google.android.material.R as materialR
class ChaptersSelectionDecoration(context: Context) : RecyclerView.ItemDecoration() { class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val bounds = Rect()
private val selection = HashSet<Long>()
private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val radius = context.resources.getDimension(materialR.dimen.abc_control_corner_material)
init { init {
paint.color = ContextCompat.getColor(context, R.color.selector_foreground) paint.color = context.getThemeColor(materialR.attr.colorSecondaryContainer, Color.LTGRAY)
paint.style = Paint.Style.FILL paint.style = Paint.Style.FILL
} }
val checkedItemsCount: Int override fun onDrawBackground(
get() = selection.size canvas: Canvas,
parent: RecyclerView,
val checkedItemsIds: Set<Long> child: View,
get() = selection bounds: RectF,
state: RecyclerView.State,
fun toggleItemChecked(id: Long) { ) {
if (!selection.remove(id)) { canvas.drawRoundRect(bounds, radius, radius, paint)
selection.add(id)
}
}
fun setItemIsChecked(id: Long, isChecked: Boolean) {
if (isChecked) {
selection.add(id)
} else {
selection.remove(id)
}
}
fun checkAll(ids: Collection<Long>) {
selection.addAll(ids)
}
fun clearSelection() {
selection.clear()
}
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
canvas.save()
if (parent.clipToPadding) {
canvas.clipRect(
parent.paddingLeft, parent.paddingTop, parent.width - parent.paddingRight,
parent.height - parent.paddingBottom
)
}
for (child in parent.children) {
val itemId = parent.getChildItemId(child)
if (itemId in selection) {
parent.getDecoratedBoundsWithMargins(child, bounds)
bounds.offset(child.translationX.toInt(), child.translationY.toInt())
canvas.drawRect(bounds, paint)
}
}
canvas.restore()
} }
} }

View File

@@ -7,6 +7,7 @@ import android.webkit.MimeTypeMap
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Scale import coil.size.Scale
import java.io.File
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
@@ -21,12 +22,12 @@ import org.koitharu.kotatsu.local.data.MangaZip
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
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.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.waitForNetwork import org.koitharu.kotatsu.utils.ext.waitForNetwork
import org.koitharu.kotatsu.utils.progress.ProgressJob import org.koitharu.kotatsu.utils.progress.ProgressJob
import java.io.File
private const val MAX_DOWNLOAD_ATTEMPTS = 3 private const val MAX_DOWNLOAD_ATTEMPTS = 3
private const val MAX_PARALLEL_DOWNLOADS = 2 private const val MAX_PARALLEL_DOWNLOADS = 2
@@ -55,22 +56,24 @@ class DownloadManager(
fun downloadManga( fun downloadManga(
manga: Manga, manga: Manga,
chaptersIds: Set<Long>?, chaptersIds: LongArray?,
startId: Int, startId: Int,
): ProgressJob<DownloadState> { ): ProgressJob<DownloadState> {
val stateFlow = MutableStateFlow<DownloadState>( val stateFlow = MutableStateFlow<DownloadState>(
DownloadState.Queued(startId = startId, manga = manga, cover = null) DownloadState.Queued(startId = startId, manga = manga, cover = null)
) )
val job = downloadMangaImpl(manga, chaptersIds, stateFlow, startId) val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, startId)
return ProgressJob(job, stateFlow) return ProgressJob(job, stateFlow)
} }
private fun downloadMangaImpl( private fun downloadMangaImpl(
manga: Manga, manga: Manga,
chaptersIds: Set<Long>?, chaptersIds: LongArray?,
outState: MutableStateFlow<DownloadState>, outState: MutableStateFlow<DownloadState>,
startId: Int, startId: Int,
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) { ): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
@Suppress("NAME_SHADOWING") var manga = manga
val chaptersIdsSet = chaptersIds?.toMutableSet()
semaphore.acquire() semaphore.acquire()
coroutineContext[WakeLockNode]?.acquire() coroutineContext[WakeLockNode]?.acquire()
outState.value = DownloadState.Preparing(startId, manga, null) outState.value = DownloadState.Preparing(startId, manga, null)
@@ -79,6 +82,9 @@ class DownloadManager(
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) } checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
var output: MangaZip? = null var output: MangaZip? = null
try { try {
if (manga.source == MangaSource.LOCAL) {
manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance")
}
val repo = MangaRepository(manga.source) val repo = MangaRepository(manga.source)
cover = runCatching { cover = runCatching {
imageLoader.execute( imageLoader.execute(
@@ -91,48 +97,51 @@ class DownloadManager(
).drawable ).drawable
}.getOrNull() }.getOrNull()
outState.value = DownloadState.Preparing(startId, manga, cover) outState.value = DownloadState.Preparing(startId, manga, cover)
val data = if (manga.chapters == null) repo.getDetails(manga) else manga val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = MangaZip.findInDir(destination, data) output = MangaZip.findInDir(destination, data)
output.prepare(data) output.prepare(data)
val coverUrl = data.largeCoverUrl ?: data.coverUrl val coverUrl = data.largeCoverUrl ?: data.coverUrl
downloadFile(coverUrl, data.publicUrl, destination).let { file -> downloadFile(coverUrl, data.publicUrl, destination).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
} }
val chapters = if (chaptersIds == null) { val chapters = checkNotNull(
data.chapters.orEmpty() if (chaptersIdsSet == null) {
} else { data.chapters
data.chapters.orEmpty().filter { x -> x.id in chaptersIds } } else {
data.chapters?.filter { x -> chaptersIdsSet.remove(x.id) }
}
) { "Chapters list must not be null" }
check(chapters.isNotEmpty()) { "Chapters list must not be empty" }
check(chaptersIdsSet.isNullOrEmpty()) {
"${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
} }
for ((chapterIndex, chapter) in chapters.withIndex()) { for ((chapterIndex, chapter) in chapters.withIndex()) {
if (chaptersIds == null || chapter.id in chaptersIds) { val pages = repo.getPages(chapter)
val pages = repo.getPages(chapter) for ((pageIndex, page) in pages.withIndex()) {
for ((pageIndex, page) in pages.withIndex()) { failsafe@ do {
failsafe@ do { try {
try { val url = repo.getPageUrl(page)
val url = repo.getPageUrl(page) val file = cache[url] ?: downloadFile(url, page.referer, destination)
val file = output.addPage(
cache[url] ?: downloadFile(url, page.referer, destination) chapter = chapter,
output.addPage( file = file,
chapter, pageNumber = pageIndex,
file, ext = MimeTypeMap.getFileExtensionFromUrl(url),
pageIndex, )
MimeTypeMap.getFileExtensionFromUrl(url) } catch (e: IOException) {
) outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
} catch (e: IOException) { connectivityManager.waitForNetwork()
outState.value = DownloadState.WaitingForNetwork(startId, data, cover) continue@failsafe
connectivityManager.waitForNetwork() }
continue@failsafe } while (false)
}
} while (false)
outState.value = DownloadState.Progress( outState.value = DownloadState.Progress(
startId, data, cover, startId, data, cover,
totalChapters = chapters.size, totalChapters = chapters.size,
currentChapter = chapterIndex, currentChapter = chapterIndex,
totalPages = pages.size, totalPages = pages.size,
currentPage = pageIndex, currentPage = pageIndex,
) )
}
} }
} }
outState.value = DownloadState.PostProcessing(startId, data, cover) outState.value = DownloadState.PostProcessing(startId, data, cover)
@@ -141,8 +150,9 @@ class DownloadManager(
} }
val localManga = localMangaRepository.getFromFile(output.file) val localManga = localMangaRepository.getFromFile(output.file)
outState.value = DownloadState.Done(startId, data, cover, localManga) outState.value = DownloadState.Done(startId, data, cover, localManga)
} catch (_: CancellationException) { } catch (e: CancellationException) {
outState.value = DownloadState.Cancelled(startId, manga, cover) outState.value = DownloadState.Cancelled(startId, manga, cover)
throw e
} catch (e: Throwable) { } catch (e: Throwable) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
e.printStackTrace() e.printStackTrace()
@@ -188,13 +198,14 @@ class DownloadManager(
} }
} }
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) = CoroutineExceptionHandler { _, throwable -> private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) =
val prevValue = outState.value CoroutineExceptionHandler { _, throwable ->
outState.value = DownloadState.Error( val prevValue = outState.value
startId = prevValue.startId, outState.value = DownloadState.Error(
manga = prevValue.manga, startId = prevValue.startId,
cover = prevValue.cover, manga = prevValue.manga,
error = throwable, cover = prevValue.cover,
) error = throwable,
} )
}
} }

View File

@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemDownloadBinding import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.progress.ProgressJob import org.koitharu.kotatsu.utils.progress.ProgressJob

View File

@@ -16,8 +16,8 @@ import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.DownloadsActivity import org.koitharu.kotatsu.download.ui.DownloadsActivity
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.utils.PendingIntentCompat import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.format
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import com.google.android.material.R as materialR import com.google.android.material.R as materialR

View File

@@ -10,6 +10,7 @@ import android.os.PowerManager
import android.widget.Toast import android.widget.Toast
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
@@ -23,7 +24,6 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseService import org.koitharu.kotatsu.base.ui.BaseService
import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.withoutChapters
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.domain.DownloadManager import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
@@ -31,10 +31,8 @@ import org.koitharu.kotatsu.download.domain.WakeLockNode
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.connectivityManager import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.throttle import org.koitharu.kotatsu.utils.ext.throttle
import org.koitharu.kotatsu.utils.ext.toArraySet
import org.koitharu.kotatsu.utils.progress.ProgressJob import org.koitharu.kotatsu.utils.progress.ProgressJob
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.collections.set
class DownloadService : BaseService() { class DownloadService : BaseService() {
@@ -66,11 +64,10 @@ class DownloadService : BaseService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
val manga = intent?.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga val manga = intent?.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga
val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet() val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)
return if (manga != null) { return if (manga != null) {
jobs[startId] = downloadManga(startId, manga, chapters) jobs[startId] = downloadManga(startId, manga, chapters)
jobCount.value = jobs.size jobCount.value = jobs.size
Toast.makeText(this, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
START_REDELIVER_INTENT START_REDELIVER_INTENT
} else { } else {
stopSelf(startId) stopSelf(startId)
@@ -97,7 +94,7 @@ class DownloadService : BaseService() {
private fun downloadManga( private fun downloadManga(
startId: Int, startId: Int,
manga: Manga, manga: Manga,
chaptersIds: Set<Long>?, chaptersIds: LongArray?,
): ProgressJob<DownloadState> { ): ProgressJob<DownloadState> {
val job = downloadManager.downloadManga(manga, chaptersIds, startId) val job = downloadManager.downloadManga(manga, chaptersIds, startId)
listenJob(job) listenJob(job)
@@ -119,7 +116,7 @@ class DownloadService : BaseService() {
(job.progressValue as? DownloadState.Done)?.let { (job.progressValue as? DownloadState.Done)?.let {
sendBroadcast( sendBroadcast(
Intent(ACTION_DOWNLOAD_COMPLETE) Intent(ACTION_DOWNLOAD_COMPLETE)
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga.withoutChapters())) .putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false))
) )
} }
notificationSwitcher.detach( notificationSwitcher.detach(
@@ -179,14 +176,38 @@ class DownloadService : BaseService() {
} }
confirmDataTransfer(context) { confirmDataTransfer(context) {
val intent = Intent(context, DownloadService::class.java) val intent = Intent(context, DownloadService::class.java)
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga)) intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
if (chaptersIds != null) { if (chaptersIds != null) {
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray()) intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
} }
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
Toast.makeText(context, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
} }
} }
fun start(context: Context, manga: Collection<Manga>) {
if (manga.isEmpty()) {
return
}
confirmDataTransfer(context) {
for (item in manga) {
val intent = Intent(context, DownloadService::class.java)
intent.putExtra(EXTRA_MANGA, ParcelableManga(item, withChapters = false))
ContextCompat.startForegroundService(context, intent)
}
}
}
fun confirmAndStart(context: Context, items: Set<Manga>) {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.save_manga)
.setMessage(R.string.batch_manga_save_confirm)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.save) { _, _ ->
start(context, items)
}.show()
}
fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL) fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
.putExtra(EXTRA_CANCEL_ID, startId) .putExtra(EXTRA_CANCEL_ID, startId)

View File

@@ -24,9 +24,10 @@ class ForegroundNotificationSwitcher(
@Synchronized @Synchronized
fun notify(startId: Int, notification: Notification) { fun notify(startId: Int, notification: Notification) {
if (notifications.isEmpty()) { if (notifications.isEmpty()) {
handler.postDelayed(StartForegroundRunnable(startId, notification), DEFAULT_DELAY) service.startForeground(startId, notification)
} else {
notificationManager.notify(startId, notification)
} }
notificationManager.notify(startId, notification)
notifications[startId] = notification notifications[startId] = notification
} }
@@ -45,16 +46,6 @@ class ForegroundNotificationSwitcher(
handler.postDelayed(NotifyRunnable(startId, notification), DEFAULT_DELAY) handler.postDelayed(NotifyRunnable(startId, notification), DEFAULT_DELAY)
} }
private inner class StartForegroundRunnable(
private val startId: Int,
private val notification: Notification,
) : Runnable {
override fun run() {
service.startForeground(startId, notification)
}
}
private inner class NotifyRunnable( private inner class NotifyRunnable(
private val startId: Int, private val startId: Int,
private val notification: Notification?, private val notification: Notification?,

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.favourites.data
import java.util.*
import org.koitharu.kotatsu.core.db.entity.SortOrder
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.parsers.model.SortOrder
fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory(
id = id,
title = title,
sortKey = sortKey,
order = SortOrder(order, SortOrder.NEWEST),
createdAt = Date(createdAt),
)

View File

@@ -3,9 +3,6 @@ package org.koitharu.kotatsu.favourites.data
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.model.FavouriteCategory
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.*
@Entity(tableName = "favourite_categories") @Entity(tableName = "favourite_categories")
class FavouriteCategoryEntity( class FavouriteCategoryEntity(
@@ -15,13 +12,4 @@ class FavouriteCategoryEntity(
@ColumnInfo(name = "sort_key") val sortKey: Int, @ColumnInfo(name = "sort_key") val sortKey: Int,
@ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "order") val order: String, @ColumnInfo(name = "order") val order: String,
) { )
fun toFavouriteCategory(id: Long? = null) = FavouriteCategory(
id = id ?: categoryId.toLong(),
title = title,
sortKey = sortKey,
order = SortOrder.values().find { x -> x.name == order } ?: SortOrder.NEWEST,
createdAt = Date(createdAt),
)
}

View File

@@ -6,36 +6,35 @@ import kotlinx.coroutines.flow.distinctUntilChanged
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.MangaEntity import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
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.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.mapItems import org.koitharu.kotatsu.utils.ext.mapItems
import org.koitharu.kotatsu.utils.ext.mapToSet
class FavouritesRepository(private val db: MangaDatabase) { class FavouritesRepository(private val db: MangaDatabase) {
suspend fun getAllManga(): List<Manga> { suspend fun getAllManga(): List<Manga> {
val entities = db.favouritesDao.findAll() val entities = db.favouritesDao.findAll()
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) } return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
} }
fun observeAll(order: SortOrder): Flow<List<Manga>> { fun observeAll(order: SortOrder): Flow<List<Manga>> {
return db.favouritesDao.observeAll(order) return db.favouritesDao.observeAll(order)
.mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) } .mapItems { it.manga.toManga(it.tags.toMangaTags()) }
} }
suspend fun getManga(categoryId: Long): List<Manga> { suspend fun getManga(categoryId: Long): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId) val entities = db.favouritesDao.findAll(categoryId)
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) } return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
} }
fun observeAll(categoryId: Long, order: SortOrder): Flow<List<Manga>> { fun observeAll(categoryId: Long, order: SortOrder): Flow<List<Manga>> {
return db.favouritesDao.observeAll(categoryId, order) return db.favouritesDao.observeAll(categoryId, order)
.mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) } .mapItems { it.manga.toManga(it.tags.toMangaTags()) }
} }
fun observeAll(categoryId: Long): Flow<List<Manga>> { fun observeAll(categoryId: Long): Flow<List<Manga>> {
@@ -43,21 +42,6 @@ class FavouritesRepository(private val db: MangaDatabase) {
.flatMapLatest { order -> observeAll(categoryId, order) } .flatMapLatest { order -> observeAll(categoryId, order) }
} }
suspend fun getManga(categoryId: Long, offset: Int): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId, offset, 20)
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
}
suspend fun getAllCategories(): List<FavouriteCategory> {
val entities = db.favouriteCategoriesDao.findAll()
return entities.map { it.toFavouriteCategory() }
}
suspend fun getCategories(mangaId: Long): List<FavouriteCategory> {
val entities = db.favouritesDao.find(mangaId)?.categories
return entities?.map { it.toFavouriteCategory() }.orEmpty()
}
fun observeCategories(): Flow<List<FavouriteCategory>> { fun observeCategories(): Flow<List<FavouriteCategory>> {
return db.favouriteCategoriesDao.observeAll().mapItems { return db.favouriteCategoriesDao.observeAll().mapItems {
it.toFavouriteCategory() it.toFavouriteCategory()
@@ -70,8 +54,8 @@ class FavouritesRepository(private val db: MangaDatabase) {
}.distinctUntilChanged() }.distinctUntilChanged()
} }
fun observeCategoriesIds(mangaId: Long): Flow<List<Long>> { fun observeCategoriesIds(mangaId: Long): Flow<Set<Long>> {
return db.favouritesDao.observeIds(mangaId) return db.favouritesDao.observeIds(mangaId).map { it.toSet() }
} }
suspend fun addCategory(title: String): FavouriteCategory { suspend fun addCategory(title: String): FavouriteCategory {
@@ -107,27 +91,37 @@ class FavouritesRepository(private val db: MangaDatabase) {
} }
} }
suspend fun addToCategory(manga: Manga, categoryId: Long) { suspend fun addToCategory(categoryId: Long, mangas: Collection<Manga>) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.withTransaction { db.withTransaction {
db.tagsDao.upsert(tags) for (manga in mangas) {
db.mangaDao.upsert(MangaEntity.from(manga), tags) val tags = manga.tags.toEntities()
val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis()) db.tagsDao.upsert(tags)
db.favouritesDao.insert(entity) db.mangaDao.upsert(manga.toEntity(), tags)
val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis())
db.favouritesDao.insert(entity)
}
} }
} }
suspend fun removeFromCategory(manga: Manga, categoryId: Long) { suspend fun removeFromFavourites(ids: Collection<Long>) {
db.favouritesDao.delete(categoryId, manga.id) db.withTransaction {
for (id in ids) {
db.favouritesDao.delete(id)
}
}
} }
suspend fun removeFromFavourites(manga: Manga) { suspend fun removeFromCategory(categoryId: Long, ids: Collection<Long>) {
db.favouritesDao.delete(manga.id) db.withTransaction {
for (id in ids) {
db.favouritesDao.delete(categoryId, id)
}
}
} }
private fun observeOrder(categoryId: Long): Flow<SortOrder> { private fun observeOrder(categoryId: Long): Flow<SortOrder> {
return db.favouriteCategoriesDao.observe(categoryId) return db.favouriteCategoriesDao.observe(categoryId)
.map { x -> SortOrder.values().find { it.name == x.order } ?: SortOrder.NEWEST } .map { x -> SortOrder(x.order, SortOrder.NEWEST) }
.distinctUntilChanged() .distinctUntilChanged()
} }
} }

View File

@@ -2,14 +2,19 @@ package org.koitharu.kotatsu.favourites.ui
import android.os.Bundle import android.os.Bundle
import android.view.* import android.view.*
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.children
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.util.ActionModeListener
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.titleRes import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding
@@ -20,11 +25,13 @@ import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.showPopupMenu
import java.util.* import java.util.*
class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(), class FavouritesContainerFragment :
FavouritesTabLongClickListener, CategoriesEditDelegate.CategoriesEditCallback { BaseFragment<FragmentFavouritesBinding>(),
FavouritesTabLongClickListener,
CategoriesEditDelegate.CategoriesEditCallback,
ActionModeListener {
private val viewModel by viewModel<FavouritesCategoriesViewModel>() private val viewModel by viewModel<FavouritesCategoriesViewModel>()
private val editDelegate by lazy(LazyThreadSafetyMode.NONE) { private val editDelegate by lazy(LazyThreadSafetyMode.NONE) {
@@ -51,6 +58,7 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
binding.pager.adapter = adapter binding.pager.adapter = adapter
pagerAdapter = adapter pagerAdapter = adapter
TabLayoutMediator(binding.tabs, binding.pager, adapter).attach() TabLayoutMediator(binding.tabs, binding.pager, adapter).attach()
actionModeDelegate.addListener(this, viewLifecycleOwner)
viewModel.categories.observe(viewLifecycleOwner, ::onCategoriesChanged) viewModel.categories.observe(viewLifecycleOwner, ::onCategoriesChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onError.observe(viewLifecycleOwner, ::onError)
@@ -61,6 +69,16 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
super.onDestroyView() super.onDestroyView()
} }
override fun onActionModeStarted(mode: ActionMode) {
binding.pager.isUserInputEnabled = false
binding.tabs.setTabsEnabled(false)
}
override fun onActionModeFinished(mode: ActionMode) {
binding.pager.isUserInputEnabled = true
binding.tabs.setTabsEnabled(true)
}
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
binding.root.updatePadding( binding.root.updatePadding(
@@ -105,22 +123,24 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
override fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean { override fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean {
val menuRes = if (category.id == 0L) R.menu.popup_category_empty else R.menu.popup_category val menuRes = if (category.id == 0L) R.menu.popup_category_empty else R.menu.popup_category
tabView.showPopupMenu(menuRes, { menu -> val menu = PopupMenu(tabView.context, tabView)
createOrderSubmenu(menu, category) menu.inflate(menuRes)
}) { createOrderSubmenu(menu.menu, category)
menu.setOnMenuItemClickListener {
when (it.itemId) { when (it.itemId) {
R.id.action_remove -> editDelegate.deleteCategory(category) R.id.action_remove -> editDelegate.deleteCategory(category)
R.id.action_rename -> editDelegate.renameCategory(category) R.id.action_rename -> editDelegate.renameCategory(category)
R.id.action_create -> editDelegate.createCategory() R.id.action_create -> editDelegate.createCategory()
R.id.action_order -> return@showPopupMenu false R.id.action_order -> return@setOnMenuItemClickListener false
else -> { else -> {
val order = CategoriesActivity.SORT_ORDERS.getOrNull(it.order) val order = CategoriesActivity.SORT_ORDERS.getOrNull(it.order)
?: return@showPopupMenu false ?: return@setOnMenuItemClickListener false
viewModel.setCategoryOrder(category.id, order) viewModel.setCategoryOrder(category.id, order)
} }
} }
true true
} }
menu.show()
return true return true
} }
@@ -146,18 +166,20 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) { private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) {
val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return
for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) { for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
val menuItem = submenu.add( val menuItem = submenu.add(R.id.group_order, Menu.NONE, i, item.titleRes)
R.id.group_order,
Menu.NONE,
i,
item.titleRes
)
menuItem.isCheckable = true menuItem.isCheckable = true
menuItem.isChecked = item == category.order menuItem.isChecked = item == category.order
} }
submenu.setGroupCheckable(R.id.group_order, true, true) submenu.setGroupCheckable(R.id.group_order, true, true)
} }
private fun TabLayout.setTabsEnabled(enabled: Boolean) {
val tabStrip = getChildAt(0) as? ViewGroup ?: return
for (tab in tabStrip.children) {
tab.isEnabled = enabled
}
}
companion object { companion object {
fun newInstance() = FavouritesContainerFragment() fun newInstance() = FavouritesContainerFragment()

View File

@@ -6,6 +6,7 @@ import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
@@ -23,11 +24,12 @@ import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.showPopupMenu
class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(), class CategoriesActivity :
BaseActivity<ActivityCategoriesBinding>(),
OnListItemClickListener<FavouriteCategory>, OnListItemClickListener<FavouriteCategory>,
View.OnClickListener, CategoriesEditDelegate.CategoriesEditCallback { View.OnClickListener,
CategoriesEditDelegate.CategoriesEditCallback {
private val viewModel by viewModel<FavouritesCategoriesViewModel>() private val viewModel by viewModel<FavouritesCategoriesViewModel>()
@@ -58,26 +60,27 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
} }
override fun onItemClick(item: FavouriteCategory, view: View) { override fun onItemClick(item: FavouriteCategory, view: View) {
view.showPopupMenu(R.menu.popup_category, { menu -> val menu = PopupMenu(view.context, view)
createOrderSubmenu(menu, item) menu.inflate(R.menu.popup_category)
}) { createOrderSubmenu(menu.menu, item)
when (it.itemId) { menu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_remove -> editDelegate.deleteCategory(item) R.id.action_remove -> editDelegate.deleteCategory(item)
R.id.action_rename -> editDelegate.renameCategory(item) R.id.action_rename -> editDelegate.renameCategory(item)
R.id.action_order -> return@showPopupMenu false R.id.action_order -> return@setOnMenuItemClickListener false
else -> { else -> {
val order = SORT_ORDERS.getOrNull(it.order) ?: return@showPopupMenu false val order = SORT_ORDERS.getOrNull(menuItem.order) ?: return@setOnMenuItemClickListener false
viewModel.setCategoryOrder(item.id, order) viewModel.setCategoryOrder(item.id, order)
} }
} }
true true
} }
menu.show()
} }
override fun onItemLongClick(item: FavouriteCategory, view: View): Boolean { override fun onItemLongClick(item: FavouriteCategory, view: View): Boolean {
reorderHelper.startDrag( val viewHolder = binding.recyclerView.findContainingViewHolder(view) ?: return false
binding.recyclerView.findContainingViewHolder(view) ?: return false reorderHelper.startDrag(viewHolder)
)
return true return true
} }
@@ -90,7 +93,7 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
binding.recyclerView.updatePadding( binding.recyclerView.updatePadding(
left = insets.left, left = insets.left,
right = insets.right, right = insets.right,
bottom = 2 * insets.bottom + binding.fabAdd.measureHeight() bottom = 2 * insets.bottom + binding.fabAdd.measureHeight(),
) )
} }

View File

@@ -2,14 +2,15 @@ package org.koitharu.kotatsu.favourites.ui.categories.select
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseBottomSheet import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
@@ -26,10 +27,10 @@ class FavouriteCategoriesDialog :
BaseBottomSheet<DialogFavoriteCategoriesBinding>(), BaseBottomSheet<DialogFavoriteCategoriesBinding>(),
OnListItemClickListener<MangaCategoryItem>, OnListItemClickListener<MangaCategoryItem>,
CategoriesEditDelegate.CategoriesEditCallback, CategoriesEditDelegate.CategoriesEditCallback,
View.OnClickListener { Toolbar.OnMenuItemClickListener {
private val viewModel by viewModel<MangaCategoriesViewModel> { private val viewModel by viewModel<MangaCategoriesViewModel> {
parametersOf(requireNotNull(arguments?.getParcelable<ParcelableManga>(MangaIntent.KEY_MANGA)).manga) parametersOf(requireNotNull(arguments?.getParcelableArrayList<ParcelableManga>(KEY_MANGA_LIST)).map { it.manga })
} }
private var adapter: MangaCategoriesAdapter? = null private var adapter: MangaCategoriesAdapter? = null
@@ -46,7 +47,7 @@ class FavouriteCategoriesDialog :
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
adapter = MangaCategoriesAdapter(this) adapter = MangaCategoriesAdapter(this)
binding.recyclerViewCategories.adapter = adapter binding.recyclerViewCategories.adapter = adapter
binding.textViewAdd.setOnClickListener(this) binding.toolbar.setOnMenuItemClickListener(this)
viewModel.content.observe(viewLifecycleOwner, this::onContentChanged) viewModel.content.observe(viewLifecycleOwner, this::onContentChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onError.observe(viewLifecycleOwner, ::onError)
@@ -57,9 +58,13 @@ class FavouriteCategoriesDialog :
super.onDestroyView() super.onDestroyView()
} }
override fun onClick(v: View) { override fun onMenuItemClick(item: MenuItem): Boolean {
when (v.id) { return when (item.itemId) {
R.id.textView_add -> editDelegate.createCategory() R.id.action_create -> {
editDelegate.createCategory()
true
}
else -> false
} }
} }
@@ -86,10 +91,15 @@ class FavouriteCategoriesDialog :
companion object { companion object {
private const val TAG = "FavouriteCategoriesDialog" private const val TAG = "FavouriteCategoriesDialog"
private const val KEY_MANGA_LIST = "manga_list"
fun show(fm: FragmentManager, manga: Manga) = FavouriteCategoriesDialog() fun show(fm: FragmentManager, manga: Manga) = Companion.show(fm, listOf(manga))
.withArgs(1) {
putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga)) fun show(fm: FragmentManager, manga: Collection<Manga>) = FavouriteCategoriesDialog().withArgs(1) {
}.show(fm, TAG) putParcelableArrayList(
KEY_MANGA_LIST,
manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withChapters = false) }
)
}.show(fm, TAG)
} }
} }

View File

@@ -4,19 +4,20 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
class MangaCategoriesViewModel( class MangaCategoriesViewModel(
private val manga: Manga, private val manga: List<Manga>,
private val favouritesRepository: FavouritesRepository private val favouritesRepository: FavouritesRepository
) : BaseViewModel() { ) : BaseViewModel() {
val content = combine( val content = combine(
favouritesRepository.observeCategories(), favouritesRepository.observeCategories(),
favouritesRepository.observeCategoriesIds(manga.id) observeCategoriesIds(),
) { all, checked -> ) { all, checked ->
all.map { all.map {
MangaCategoryItem( MangaCategoryItem(
@@ -30,9 +31,9 @@ class MangaCategoriesViewModel(
fun setChecked(categoryId: Long, isChecked: Boolean) { fun setChecked(categoryId: Long, isChecked: Boolean) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
if (isChecked) { if (isChecked) {
favouritesRepository.addToCategory(manga, categoryId) favouritesRepository.addToCategory(categoryId, manga)
} else { } else {
favouritesRepository.removeFromCategory(manga, categoryId) favouritesRepository.removeFromCategory(categoryId, manga.ids())
} }
} }
} }
@@ -42,4 +43,25 @@ class MangaCategoriesViewModel(
favouritesRepository.addCategory(name) favouritesRepository.addCategory(name)
} }
} }
private fun observeCategoriesIds() = if (manga.size == 1) {
// Fast path
favouritesRepository.observeCategoriesIds(manga[0].id)
} else {
combine(
manga.map { favouritesRepository.observeCategoriesIds(it.id) }
) { array ->
val result = HashSet<Long>()
var isFirst = true
for (ids in array) {
if (isFirst) {
result.addAll(ids)
isFirst = false
} else {
result.retainAll(ids.toSet())
}
}
result
}
}
} }

View File

@@ -1,13 +1,13 @@
package org.koitharu.kotatsu.favourites.ui.list package org.koitharu.kotatsu.favourites.ui.list
import android.view.Menu import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import androidx.appcompat.view.ActionMode
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
class FavouritesListFragment : MangaListFragment() { class FavouritesListFragment : MangaListFragment() {
@@ -23,17 +23,27 @@ class FavouritesListFragment : MangaListFragment() {
override fun onScrolledToEnd() = Unit override fun onScrolledToEnd() = Unit
override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) { override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
super.onCreatePopupMenu(inflater, menu, data) mode.menuInflater.inflate(R.menu.mode_favourites, menu)
inflater.inflate(R.menu.popup_favourites, menu) return super.onCreateActionMode(mode, menu)
} }
override fun onPopupMenuItemSelected(item: MenuItem, data: Manga) = when (item.itemId) { override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
R.id.action_remove -> { menu.findItem(R.id.action_save)?.isVisible = selectedItems.none {
viewModel.removeFromFavourites(data) it.source == MangaSource.LOCAL
true }
return super.onPrepareActionMode(mode, menu)
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_remove -> {
viewModel.removeFromFavourites(selectedItemsIds)
mode.finish()
true
}
else -> super.onActionItemClicked(mode, item)
} }
else -> super.onPopupMenuItemSelected(item, data)
} }
companion object { companion object {

View File

@@ -13,7 +13,6 @@ 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.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@@ -56,12 +55,15 @@ class FavouritesListViewModel(
override fun onRetry() = Unit override fun onRetry() = Unit
fun removeFromFavourites(manga: Manga) { fun removeFromFavourites(ids: Set<Long>) {
if (ids.isEmpty()) {
return
}
launchJob { launchJob {
if (categoryId == 0L) { if (categoryId == 0L) {
repository.removeFromFavourites(manga) repository.removeFromFavourites(ids)
} else { } else {
repository.removeFromCategory(manga, categoryId) repository.removeFromCategory(categoryId, ids)
} }
} }
} }

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.history.data
import java.util.*
import org.koitharu.kotatsu.core.model.MangaHistory
fun HistoryEntity.toMangaHistory() = MangaHistory(
createdAt = Date(createdAt),
updatedAt = Date(updatedAt),
chapterId = chapterId,
page = page,
scroll = scroll.toInt()
)

View File

@@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.Flow
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
@Dao @Dao
abstract class HistoryDao { abstract class HistoryDao {
@@ -23,8 +22,15 @@ abstract class HistoryDao {
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history)") @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history)")
abstract suspend fun findAllManga(): List<MangaEntity> abstract suspend fun findAllManga(): List<MangaEntity>
@Query("SELECT * FROM tags WHERE tag_id IN (SELECT tag_id FROM manga_tags WHERE manga_id IN (SELECT manga_id FROM history))") @Query(
abstract suspend fun findAllTags(): List<TagEntity> """SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
INNER JOIN history ON history.manga_id = manga_tags.manga_id
GROUP BY manga_tags.tag_id
ORDER BY COUNT(manga_tags.manga_id) DESC
LIMIT :limit"""
)
abstract suspend fun findPopularTags(limit: Int): List<TagEntity>
@Query("SELECT * FROM history WHERE manga_id = :id") @Query("SELECT * FROM history WHERE manga_id = :id")
abstract suspend fun find(id: Long): HistoryEntity? abstract suspend fun find(id: Long): HistoryEntity?
@@ -32,6 +38,9 @@ abstract class HistoryDao {
@Query("SELECT * FROM history WHERE manga_id = :id") @Query("SELECT * FROM history WHERE manga_id = :id")
abstract fun observe(id: Long): Flow<HistoryEntity?> abstract fun observe(id: Long): Flow<HistoryEntity?>
@Query("SELECT COUNT(*) FROM history")
abstract fun observeCount(): Flow<Int>
@Query("DELETE FROM history") @Query("DELETE FROM history")
abstract suspend fun clear() abstract suspend fun clear()
@@ -60,5 +69,4 @@ abstract class HistoryDao {
true true
} else false } else false
} }
} }

View File

@@ -5,11 +5,10 @@ import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.model.MangaHistory
import java.util.*
@Entity( @Entity(
tableName = "history", foreignKeys = [ tableName = "history",
foreignKeys = [
ForeignKey( ForeignKey(
entity = MangaEntity::class, entity = MangaEntity::class,
parentColumns = ["manga_id"], parentColumns = ["manga_id"],
@@ -26,13 +25,4 @@ class HistoryEntity(
@ColumnInfo(name = "chapter_id") val chapterId: Long, @ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "page") val page: Int, @ColumnInfo(name = "page") val page: Int,
@ColumnInfo(name = "scroll") val scroll: Float, @ColumnInfo(name = "scroll") val scroll: Float,
) { )
fun toMangaHistory() = MangaHistory(
createdAt = Date(createdAt),
updatedAt = Date(updatedAt),
chapterId = chapterId,
page = page,
scroll = scroll.toInt()
)
}

View File

@@ -19,5 +19,5 @@ class HistoryWithManga(
entityColumn = "tag_id", entityColumn = "tag_id",
associateBy = Junction(MangaTagsEntity::class) associateBy = Junction(MangaTagsEntity::class)
) )
val tags: List<TagEntity> val tags: List<TagEntity>,
) )

View File

@@ -2,18 +2,18 @@ package org.koitharu.kotatsu.history.domain
import androidx.room.withTransaction import androidx.room.withTransaction
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
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.MangaEntity import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.toMangaHistory
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.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.mapItems import org.koitharu.kotatsu.utils.ext.mapItems
import org.koitharu.kotatsu.utils.ext.mapToSet
class HistoryRepository( class HistoryRepository(
private val db: MangaDatabase, private val db: MangaDatabase,
@@ -23,20 +23,25 @@ class HistoryRepository(
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> { suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
val entities = db.historyDao.findAll(offset, limit) val entities = db.historyDao.findAll(offset, limit)
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) } return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
}
suspend fun getLastOrNull(): Manga? {
val entity = db.historyDao.findAll(0, 1).firstOrNull() ?: return null
return entity.manga.toManga(entity.tags.toMangaTags())
} }
fun observeAll(): Flow<List<Manga>> { fun observeAll(): Flow<List<Manga>> {
return db.historyDao.observeAll().mapItems { return db.historyDao.observeAll().mapItems {
it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) it.manga.toManga(it.tags.toMangaTags())
} }
} }
fun observeAllWithHistory(): Flow<List<MangaWithHistory>> { fun observeAllWithHistory(): Flow<List<MangaWithHistory>> {
return db.historyDao.observeAll().mapItems { return db.historyDao.observeAll().mapItems {
MangaWithHistory( MangaWithHistory(
it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)), it.manga.toManga(it.tags.toMangaTags()),
it.history.toMangaHistory() it.history.toMangaHistory(),
) )
} }
} }
@@ -47,14 +52,20 @@ class HistoryRepository(
} }
} }
fun observeHasItems(): Flow<Boolean> {
return db.historyDao.observeCount()
.map { it > 0 }
.distinctUntilChanged()
}
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) { suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
if (manga.isNsfw && settings.isHistoryExcludeNsfw) { if (manga.isNsfw && settings.isHistoryExcludeNsfw) {
return return
} }
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag) val tags = manga.tags.toEntities()
db.withTransaction { db.withTransaction {
db.tagsDao.upsert(tags) db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags) db.mangaDao.upsert(manga.toEntity(), tags)
db.historyDao.upsert( db.historyDao.upsert(
HistoryEntity( HistoryEntity(
mangaId = manga.id, mangaId = manga.id,
@@ -62,7 +73,7 @@ class HistoryRepository(
updatedAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis(),
chapterId = chapterId, chapterId = chapterId,
page = page, page = page,
scroll = scroll.toFloat() // we migrate to int, but decide to not update database scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
) )
) )
trackingRepository.upsert(manga) trackingRepository.upsert(manga)
@@ -81,17 +92,25 @@ class HistoryRepository(
db.historyDao.delete(manga.id) db.historyDao.delete(manga.id)
} }
suspend fun delete(ids: Collection<Long>) {
db.withTransaction {
for (id in ids) {
db.historyDao.delete(id)
}
}
}
/** /**
* Try to replace one manga with another one * Try to replace one manga with another one
* Useful for replacing saved manga on deleting it with remove source * Useful for replacing saved manga on deleting it with remove source
*/ */
suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) { suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) {
if (alternative == null || db.mangaDao.update(MangaEntity.from(alternative)) <= 0) { if (alternative == null || db.mangaDao.update(alternative.toEntity()) <= 0) {
db.historyDao.delete(manga.id) db.historyDao.delete(manga.id)
} }
} }
suspend fun getAllTags(): Set<MangaTag> { suspend fun getPopularTags(limit: Int): List<MangaTag> {
return db.historyDao.findAllTags().mapToSet { x -> x.toMangaTag() } return db.historyDao.findPopularTags(limit).map { x -> x.toMangaTag() }
} }
} }

View File

@@ -5,13 +5,12 @@ import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.view.ActionMode
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.ellipsize
class HistoryListFragment : MangaListFragment() { class HistoryListFragment : MangaListFragment() {
@@ -20,7 +19,6 @@ class HistoryListFragment : MangaListFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
viewModel.onItemRemoved.observe(viewLifecycleOwner, ::onItemRemoved)
viewModel.isGroupingEnabled.observe(viewLifecycleOwner) { viewModel.isGroupingEnabled.observe(viewLifecycleOwner) {
activity?.invalidateOptionsMenu() activity?.invalidateOptionsMenu()
} }
@@ -59,30 +57,29 @@ class HistoryListFragment : MangaListFragment() {
} }
} }
override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) { override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
super.onCreatePopupMenu(inflater, menu, data) mode.menuInflater.inflate(R.menu.mode_history, menu)
inflater.inflate(R.menu.popup_history, menu) return super.onCreateActionMode(mode, menu)
} }
override fun onPopupMenuItemSelected(item: MenuItem, data: Manga): Boolean { override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none {
it.source == MangaSource.LOCAL
}
return super.onPrepareActionMode(mode, menu)
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.action_remove -> { R.id.action_remove -> {
viewModel.removeFromHistory(data) viewModel.removeFromHistory(selectedItemsIds)
mode.finish()
true true
} }
else -> super.onPopupMenuItemSelected(item, data) else -> super.onActionItemClicked(mode, item)
} }
} }
private fun onItemRemoved(item: Manga) {
Snackbar.make(
binding.recyclerView, getString(
R.string._s_removed_from_history,
item.title.ellipsize(16)
), Snackbar.LENGTH_SHORT
).show()
}
companion object { companion object {
fun newInstance() = HistoryListFragment() fun newInstance() = HistoryListFragment()

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.history.ui
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import java.util.*
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -13,14 +15,10 @@ import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.MangaWithHistory import org.koitharu.kotatsu.history.domain.MangaWithHistory
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.daysDiff import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.onFirst import org.koitharu.kotatsu.utils.ext.onFirst
import java.util.*
import java.util.concurrent.TimeUnit
class HistoryListViewModel( class HistoryListViewModel(
private val repository: HistoryRepository, private val repository: HistoryRepository,
@@ -29,7 +27,6 @@ class HistoryListViewModel(
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
) : MangaListViewModel(settings) { ) : MangaListViewModel(settings) {
val onItemRemoved = SingleLiveEvent<Manga>()
val isGroupingEnabled = MutableLiveData<Boolean>() val isGroupingEnabled = MutableLiveData<Boolean>()
private val historyGrouping = settings.observe() private val historyGrouping = settings.observe()
@@ -72,10 +69,12 @@ class HistoryListViewModel(
} }
} }
fun removeFromHistory(manga: Manga) { fun removeFromHistory(ids: Set<Long>) {
if (ids.isEmpty()) {
return
}
launchJob { launchJob {
repository.delete(manga) repository.delete(ids)
onItemRemoved.call(manga)
shortcutsRepository.updateShortcuts() shortcutsRepository.updateShortcuts()
} }
} }

View File

@@ -43,7 +43,7 @@ class ListModeSelectDialog : AlertDialogFragment<DialogListModeBinding>(),
binding.textViewGridTitle.isVisible = mode == ListMode.GRID binding.textViewGridTitle.isVisible = mode == ListMode.GRID
binding.sliderGrid.isVisible = mode == ListMode.GRID binding.sliderGrid.isVisible = mode == ListMode.GRID
binding.sliderGrid.setLabelFormatter(IntPercentLabelFormatter()) binding.sliderGrid.setLabelFormatter(IntPercentLabelFormatter(view.context))
binding.sliderGrid.setValueRounded(settings.gridSize.toFloat()) binding.sliderGrid.setValueRounded(settings.gridSize.toFloat())
binding.sliderGrid.addOnSliderTouchListener(this) binding.sliderGrid.addOnSliderTouchListener(this)

View File

@@ -3,9 +3,11 @@ package org.koitharu.kotatsu.list.ui
import android.os.Bundle import android.os.Bundle
import android.view.* import android.view.*
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.collection.ArraySet
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.GravityCompat import androidx.core.view.isNotEmpty
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@@ -24,24 +26,31 @@ import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter.Companion.ITEM_TYPE_MANGA_GRID import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter.Companion.ITEM_TYPE_MANGA_GRID
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.main.ui.AppBarOwner import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.main.ui.MainActivity import org.koitharu.kotatsu.main.ui.MainActivity
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.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
abstract class MangaListFragment : abstract class MangaListFragment :
BaseFragment<FragmentListBinding>(), BaseFragment<FragmentListBinding>(),
PaginationScrollListener.Callback, PaginationScrollListener.Callback,
MangaListListener, MangaListListener,
SwipeRefreshLayout.OnRefreshListener { SwipeRefreshLayout.OnRefreshListener,
ActionMode.Callback {
private var listAdapter: MangaListAdapter? = null private var listAdapter: MangaListAdapter? = null
private var paginationListener: PaginationScrollListener? = null private var paginationListener: PaginationScrollListener? = null
private var selectionDecoration: MangaSelectionDecoration? = null
private var actionMode: ActionMode? = null
private val spanResolver = MangaListSpanResolver() private val spanResolver = MangaListSpanResolver()
private val spanSizeLookup = SpanSizeLookup() private val spanSizeLookup = SpanSizeLookup()
private val listCommitCallback = Runnable { private val listCommitCallback = Runnable {
@@ -51,6 +60,12 @@ abstract class MangaListFragment :
protected abstract val viewModel: MangaListViewModel protected abstract val viewModel: MangaListViewModel
protected val selectedItemsIds: Set<Long>
get() = selectionDecoration?.checkedItemsIds?.toSet().orEmpty()
protected val selectedItems: Set<Manga>
get() = collectSelectedItems()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setHasOptionsMenu(true) setHasOptionsMenu(true)
@@ -68,10 +83,12 @@ abstract class MangaListFragment :
lifecycleOwner = viewLifecycleOwner, lifecycleOwner = viewLifecycleOwner,
listener = this, listener = this,
) )
selectionDecoration = MangaSelectionDecoration(view.context)
paginationListener = PaginationScrollListener(4, this) paginationListener = PaginationScrollListener(4, this)
with(binding.recyclerView) { with(binding.recyclerView) {
setHasFixedSize(true) setHasFixedSize(true)
adapter = listAdapter adapter = listAdapter
addItemDecoration(selectionDecoration!!)
addOnScrollListener(paginationListener!!) addOnScrollListener(paginationListener!!)
} }
with(binding.swipeRefreshLayout) { with(binding.swipeRefreshLayout) {
@@ -91,6 +108,7 @@ abstract class MangaListFragment :
override fun onDestroyView() { override fun onDestroyView() {
listAdapter = null listAdapter = null
paginationListener = null paginationListener = null
selectionDecoration = null
spanSizeLookup.invalidateCache() spanSizeLookup.invalidateCache()
super.onDestroyView() super.onDestroyView()
} }
@@ -109,22 +127,28 @@ abstract class MangaListFragment :
} }
override fun onItemClick(item: Manga, view: View) { override fun onItemClick(item: Manga, view: View) {
if (selectionDecoration?.checkedItemsCount != 0) {
selectionDecoration?.toggleItemChecked(item.id)
if (selectionDecoration?.checkedItemsCount == 0) {
actionMode?.finish()
} else {
actionMode?.invalidate()
binding.recyclerView.invalidateItemDecorations()
}
return
}
startActivity(DetailsActivity.newIntent(context ?: return, item)) startActivity(DetailsActivity.newIntent(context ?: return, item))
} }
override fun onItemLongClick(item: Manga, view: View): Boolean { override fun onItemLongClick(item: Manga, view: View): Boolean {
val menu = PopupMenu(context ?: return false, view) if (actionMode == null) {
onCreatePopupMenu(menu.menuInflater, menu.menu, item) actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
return if (menu.menu.hasVisibleItems()) {
menu.setOnMenuItemClickListener {
onPopupMenuItemSelected(it, item)
}
menu.gravity = GravityCompat.END or Gravity.TOP
menu.show()
true
} else {
false
} }
return actionMode?.also {
selectionDecoration?.setItemIsChecked(item.id, true)
binding.recyclerView.invalidateItemDecorations()
it.invalidate()
} != null
} }
@CallSuper @CallSuper
@@ -238,12 +262,67 @@ abstract class MangaListFragment :
addOnLayoutChangeListener(spanResolver) addOnLayoutChangeListener(spanResolver)
} }
} }
selectionDecoration?.let { addItemDecoration(it) }
} }
} }
protected open fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) = Unit override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
return menu.isNotEmpty()
}
protected open fun onPopupMenuItemSelected(item: MenuItem, data: Manga) = false @CallSuper
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.title = selectionDecoration?.checkedItemsCount?.toString()
return true
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_select_all -> {
val ids = listAdapter?.items?.mapNotNull {
(it as? MangaItemModel)?.id
} ?: return false
selectionDecoration?.checkAll(ids)
binding.recyclerView.invalidateItemDecorations()
mode.invalidate()
true
}
R.id.action_share -> {
ShareHelper(requireContext()).shareMangaLinks(selectedItems)
mode.finish()
true
}
R.id.action_favourite -> {
FavouriteCategoriesDialog.show(childFragmentManager, selectedItems)
mode.finish()
true
}
R.id.action_save -> {
DownloadService.confirmAndStart(requireContext(), selectedItems)
mode.finish()
true
}
else -> false
}
}
override fun onDestroyActionMode(mode: ActionMode) {
selectionDecoration?.clearSelection()
binding.recyclerView.invalidateItemDecorations()
actionMode = null
}
private fun collectSelectedItems(): Set<Manga> {
val checkedIds = selectionDecoration?.checkedItemsIds ?: return emptySet()
val items = listAdapter?.items ?: return emptySet()
val result = ArraySet<Manga>(checkedIds.size)
for (item in items) {
if (item is MangaItemModel && item.id in checkedIds) {
result.add(item.manga)
}
}
return result
}
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() { private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {

View File

@@ -0,0 +1,71 @@
package org.koitharu.kotatsu.list.ui
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.view.View
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.utils.ext.getItem
import org.koitharu.kotatsu.utils.ext.getThemeColor
import com.google.android.material.R as materialR
class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
private val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74
)
init {
hasBackground = false
hasForeground = true
isIncludeDecorAndMargins = false
paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width)
checkIcon?.setTint(strokeColor)
}
override fun getItemId(parent: RecyclerView, child: View): Long {
val holder = parent.getChildViewHolder(child) ?: return NO_ID
val item = holder.getItem(MangaItemModel::class.java) ?: return NO_ID
return item.id
}
override fun onDrawForeground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) {
val radius = (child as? CardView)?.radius ?: 0f
paint.color = fillColor
paint.style = Paint.Style.FILL
canvas.drawRoundRect(bounds, radius, radius, paint)
paint.color = strokeColor
paint.style = Paint.Style.STROKE
canvas.drawRoundRect(bounds, radius, radius, paint)
checkIcon?.run {
setBounds(
(bounds.left + iconOffset).toInt(),
(bounds.top + iconOffset).toInt(),
(bounds.left + iconOffset + intrinsicWidth).toInt(),
(bounds.top + iconOffset + intrinsicHeight).toInt(),
)
draw(canvas)
}
}
}

View File

@@ -3,9 +3,9 @@ package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
data class MangaGridModel( data class MangaGridModel(
val id: Long, override val id: Long,
val title: String, val title: String,
val coverUrl: String, val coverUrl: String,
val manga: Manga, override val manga: Manga,
val counter: Int, val counter: Int,
) : ListModel ) : MangaItemModel

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.parsers.model.Manga
sealed interface MangaItemModel : ListModel {
val id: Long
val manga: Manga
}

View File

@@ -3,12 +3,12 @@ package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
data class MangaListDetailedModel( data class MangaListDetailedModel(
val id: Long, override val id: Long,
val title: String, val title: String,
val subtitle: String?, val subtitle: String?,
val tags: String, val tags: String,
val coverUrl: String, val coverUrl: String,
val rating: String?, val rating: String?,
val manga: Manga, override val manga: Manga,
val counter: Int, val counter: Int,
) : ListModel ) : MangaItemModel

View File

@@ -3,10 +3,10 @@ package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
data class MangaListModel( data class MangaListModel(
val id: Long, override val id: Long,
val title: String, val title: String,
val subtitle: String, val subtitle: String,
val coverUrl: String, val coverUrl: String,
val manga: Manga, override val manga: Manga,
val counter: Int, val counter: Int,
) : ListModel ) : MangaItemModel

View File

@@ -9,6 +9,9 @@ import android.view.MenuItem
import android.view.View import android.view.View
import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.view.ActionMode
import androidx.core.net.toFile
import androidx.core.net.toUri
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
@@ -16,8 +19,7 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.utils.progress.Progress import org.koitharu.kotatsu.utils.progress.Progress
class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmSuppressWildcards Uri>> { class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmSuppressWildcards Uri>> {
@@ -46,7 +48,7 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmS
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
viewModel.onMangaRemoved.observe(viewLifecycleOwner, ::onItemRemoved) viewModel.onMangaRemoved.observe(viewLifecycleOwner) { onItemRemoved() }
viewModel.importProgress.observe(viewLifecycleOwner, ::onImportProgressChanged) viewModel.importProgress.observe(viewLifecycleOwner, ::onImportProgressChanged)
} }
@@ -97,35 +99,41 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmS
viewModel.importFiles(result) viewModel.importFiles(result)
} }
override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) { override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
super.onCreatePopupMenu(inflater, menu, data) mode.menuInflater.inflate(R.menu.mode_local, menu)
inflater.inflate(R.menu.popup_local, menu) return super.onCreateActionMode(mode, menu)
} }
override fun onPopupMenuItemSelected(item: MenuItem, data: Manga): Boolean { override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.action_delete -> { R.id.action_remove -> {
MaterialAlertDialogBuilder(context ?: return false) showDeletionConfirm(selectedItemsIds, mode)
.setTitle(R.string.delete_manga)
.setMessage(getString(R.string.text_delete_local_manga, data.title))
.setPositiveButton(R.string.delete) { _, _ ->
viewModel.delete(data)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
true true
} }
else -> super.onPopupMenuItemSelected(item, data) R.id.action_share -> {
val files = selectedItems.map { it.url.toUri().toFile() }
ShareHelper(requireContext()).shareCbz(files)
mode.finish()
true
}
else -> super.onActionItemClicked(mode, item)
} }
} }
private fun onItemRemoved(item: Manga) { private fun showDeletionConfirm(ids: Set<Long>, mode: ActionMode) {
Snackbar.make( MaterialAlertDialogBuilder(context ?: return)
binding.recyclerView, getString( .setTitle(R.string.delete_manga)
R.string._s_deleted_from_local_storage, .setMessage(getString(R.string.text_delete_local_manga_batch))
item.title.ellipsize(16) .setPositiveButton(R.string.delete) { _, _ ->
), Snackbar.LENGTH_SHORT viewModel.delete(ids)
).show() mode.finish()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun onItemRemoved() {
Snackbar.make(binding.recyclerView, R.string.removal_completed, Snackbar.LENGTH_SHORT).show()
} }
private fun onImportProgressChanged(progress: Progress?) { private fun onImportProgressChanged(progress: Progress?) {

View File

@@ -3,10 +3,12 @@ package org.koitharu.kotatsu.local.ui
import android.net.Uri import android.net.Uri
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import java.io.IOException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.ShortcutsRepository import org.koitharu.kotatsu.core.os.ShortcutsRepository
@@ -19,7 +21,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.progress.Progress import org.koitharu.kotatsu.utils.progress.Progress
import java.io.IOException
class LocalListViewModel( class LocalListViewModel(
private val repository: LocalMangaRepository, private val repository: LocalMangaRepository,
@@ -28,7 +29,7 @@ class LocalListViewModel(
private val shortcutsRepository: ShortcutsRepository, private val shortcutsRepository: ShortcutsRepository,
) : MangaListViewModel(settings) { ) : MangaListViewModel(settings) {
val onMangaRemoved = SingleLiveEvent<Manga>() val onMangaRemoved = SingleLiveEvent<Unit>()
val importProgress = MutableLiveData<Progress?>(null) val importProgress = MutableLiveData<Progress?>(null)
private val listError = MutableStateFlow<Throwable?>(null) private val listError = MutableStateFlow<Throwable?>(null)
private val mangaList = MutableStateFlow<List<Manga>?>(null) private val mangaList = MutableStateFlow<List<Manga>?>(null)
@@ -87,18 +88,23 @@ class LocalListViewModel(
} }
} }
fun delete(manga: Manga) { fun delete(ids: Set<Long>) {
launchJob { launchLoadingJob {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val original = repository.getRemoteManga(manga) val itemsToRemove = checkNotNull(mangaList.value).filter { it.id in ids }
repository.delete(manga) || throw IOException("Unable to delete file") for (manga in itemsToRemove) {
runCatching { val original = repository.getRemoteManga(manga)
historyRepository.deleteOrSwap(manga, original) repository.delete(manga) || throw IOException("Unable to delete file")
runCatching {
historyRepository.deleteOrSwap(manga, original)
}
mangaList.update { list ->
list?.filterNot { it.id == manga.id }
}
} }
mangaList.value = mangaList.value?.filterNot { it.id == manga.id }
} }
shortcutsRepository.updateShortcuts() shortcutsRepository.updateShortcuts()
onMangaRemoved.call(manga) onMangaRemoved.call(Unit)
} }
} }

View File

@@ -8,18 +8,15 @@ import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup.MarginLayoutParams import android.view.ViewGroup.MarginLayoutParams
import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.view.ActionMode
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.GravityCompat import androidx.core.view.*
import androidx.core.view.ViewCompat
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.drawerlayout.widget.DrawerLayout import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.navigation.NavigationView import com.google.android.material.navigation.NavigationView
@@ -134,6 +131,7 @@ class MainActivity :
viewModel.onOpenReader.observe(this, this::onOpenReader) viewModel.onOpenReader.observe(this, this::onOpenReader)
viewModel.onError.observe(this, this::onError) viewModel.onError.observe(this, this::onError)
viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged)
viewModel.remoteSources.observe(this, this::updateSideMenu) viewModel.remoteSources.observe(this, this::updateSideMenu)
viewModel.isSuggestionsEnabled.observe(this, this::setSuggestionsEnabled) viewModel.isSuggestionsEnabled.observe(this, this::setSuggestionsEnabled)
} }
@@ -288,6 +286,16 @@ class MainActivity :
}.show() }.show()
} }
override fun onSupportActionModeStarted(mode: ActionMode) {
super.onSupportActionModeStarted(mode)
adjustDrawerLock()
}
override fun onSupportActionModeFinished(mode: ActionMode) {
super.onSupportActionModeFinished(mode)
adjustDrawerLock()
}
private fun onOpenReader(manga: Manga) { private fun onOpenReader(manga: Manga) {
val options = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val options = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ActivityOptions.makeClipRevealAnimation( ActivityOptions.makeClipRevealAnimation(
@@ -302,23 +310,15 @@ class MainActivity :
} }
private fun onError(e: Throwable) { private fun onError(e: Throwable) {
Snackbar.make(binding.container, e.getDisplayMessage(resources), Snackbar.LENGTH_SHORT) Snackbar.make(binding.container, e.getDisplayMessage(resources), Snackbar.LENGTH_SHORT).show()
.show()
} }
private fun onLoadingStateChanged(isLoading: Boolean) { private fun onLoadingStateChanged(isLoading: Boolean) {
binding.fab.isEnabled = !isLoading binding.fab.isEnabled = !isLoading
if (isLoading) { }
binding.fab.setImageDrawable(
CircularProgressDrawable(this).also { private fun onResumeEnabledChanged(isEnabled: Boolean) {
it.setColorSchemeColors(R.color.kotatsu_onPrimaryContainer) adjustFabVisibility(isResumeEnabled = isEnabled)
it.strokeWidth = resources.resolveDp(3.5f)
it.start()
}
)
} else {
binding.fab.setImageResource(R.drawable.ic_read_fill)
}
} }
private fun updateSideMenu(remoteSources: List<MangaSource>) { private fun updateSideMenu(remoteSources: List<MangaSource>) {
@@ -372,14 +372,14 @@ class MainActivity :
} }
private fun onSearchOpened() { private fun onSearchOpened() {
drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
drawerToggle?.isDrawerIndicatorEnabled = false drawerToggle?.isDrawerIndicatorEnabled = false
adjustDrawerLock()
adjustFabVisibility(isSearchOpened = true) adjustFabVisibility(isSearchOpened = true)
} }
private fun onSearchClosed() { private fun onSearchClosed() {
drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED)
drawerToggle?.isDrawerIndicatorEnabled = true drawerToggle?.isDrawerIndicatorEnabled = true
adjustDrawerLock()
adjustFabVisibility(isSearchOpened = false) adjustFabVisibility(isSearchOpened = false)
} }
@@ -397,9 +397,27 @@ class MainActivity :
} }
private fun adjustFabVisibility( private fun adjustFabVisibility(
isResumeEnabled: Boolean = viewModel.isResumeEnabled.value == true,
topFragment: Fragment? = supportFragmentManager.findFragmentByTag(TAG_PRIMARY), topFragment: Fragment? = supportFragmentManager.findFragmentByTag(TAG_PRIMARY),
isSearchOpened: Boolean = supportFragmentManager.findFragmentByTag(TAG_SEARCH)?.isVisible == true, isSearchOpened: Boolean = supportFragmentManager.findFragmentByTag(TAG_SEARCH)?.isVisible == true,
) { ) {
if (!isSearchOpened && topFragment is HistoryListFragment) binding.fab.show() else binding.fab.hide() val fab = binding.fab
if (isResumeEnabled && !isSearchOpened && topFragment is HistoryListFragment) {
if (!fab.isVisible) {
fab.show()
}
} else {
if (fab.isVisible) {
fab.hide()
}
}
}
private fun adjustDrawerLock() {
val drawer = drawer ?: return
val isLocked = actionModeDelegate.isActionModeStarted || (drawerToggle?.isDrawerIndicatorEnabled == false)
drawer.setDrawerLockMode(
if (isLocked) DrawerLayout.LOCK_MODE_LOCKED_CLOSED else DrawerLayout.LOCK_MODE_UNLOCKED
)
} }
} }

View File

@@ -27,6 +27,10 @@ class MainViewModel(
.map { settings.isSuggestionsEnabled } .map { settings.isSuggestionsEnabled }
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val isResumeEnabled = historyRepository
.observeHasItems()
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val remoteSources = settings.observe() val remoteSources = settings.observe()
.filter { it == AppSettings.KEY_SOURCES_ORDER || it == AppSettings.KEY_SOURCES_HIDDEN } .filter { it == AppSettings.KEY_SOURCES_ORDER || it == AppSettings.KEY_SOURCES_HIDDEN }
.onStart { emit("") } .onStart { emit("") }
@@ -35,8 +39,7 @@ class MainViewModel(
fun openLastReader() { fun openLastReader() {
launchLoadingJob { launchLoadingJob {
val manga = historyRepository.getList(0, 1).firstOrNull() val manga = historyRepository.getLastOrNull() ?: throw EmptyHistoryException()
?: throw EmptyHistoryException()
onOpenReader.call(manga) onOpenReader.call(manga)
} }
} }

View File

@@ -44,6 +44,7 @@ import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonReaderFragment import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonReaderFragment
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.utils.GridTouchHelper import org.koitharu.kotatsu.utils.GridTouchHelper
import org.koitharu.kotatsu.utils.ScreenOrientationHelper import org.koitharu.kotatsu.utils.ScreenOrientationHelper
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
@@ -158,7 +159,7 @@ class ReaderActivity :
) )
} }
R.id.action_settings -> { R.id.action_settings -> {
startActivity(SimpleSettingsActivity.newReaderSettingsIntent(this)) startActivity(SettingsActivity.newReaderSettingsIntent(this))
} }
R.id.action_chapters -> { R.id.action_chapters -> {
ChaptersBottomSheet.show( ChaptersBottomSheet.show(
@@ -409,18 +410,18 @@ class ReaderActivity :
fun newIntent(context: Context, manga: Manga): Intent { fun newIntent(context: Context, manga: Manga): Intent {
return Intent(context, ReaderActivity::class.java) return Intent(context, ReaderActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga)) .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
} }
fun newIntent(context: Context, manga: Manga, branch: String?): Intent { fun newIntent(context: Context, manga: Manga, branch: String?): Intent {
return Intent(context, ReaderActivity::class.java) return Intent(context, ReaderActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga)) .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
.putExtra(EXTRA_BRANCH, branch) .putExtra(EXTRA_BRANCH, branch)
} }
fun newIntent(context: Context, manga: Manga, state: ReaderState?): Intent { fun newIntent(context: Context, manga: Manga, state: ReaderState?): Intent {
return Intent(context, ReaderActivity::class.java) return Intent(context, ReaderActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga)) .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
.putExtra(EXTRA_STATE, state) .putExtra(EXTRA_STATE, state)
} }

View File

@@ -143,7 +143,8 @@ class ReaderViewModel(
val page = getCurrentPage() ?: error("Page not found") val page = getCurrentPage() ?: error("Page not found")
externalStorageHelper.savePage(page, destination) externalStorageHelper.savePage(page, destination)
onPageSaved.postCall(destination) onPageSaved.postCall(destination)
} catch (_: CancellationException) { } catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
e.printStackTrace() e.printStackTrace()

View File

@@ -1,80 +0,0 @@
package org.koitharu.kotatsu.reader.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.ViewGroup
import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.commit
import com.google.android.material.appbar.AppBarLayout
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivitySettingsSimpleBinding
import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.*
class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>(), AppBarOwner {
override val appBar: AppBarLayout
get() = binding.appbar
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivitySettingsSimpleBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportFragmentManager.commit {
replace(
R.id.container,
when (intent?.action) {
Intent.ACTION_MANAGE_NETWORK_USAGE -> NetworkSettingsFragment()
ACTION_READER -> ReaderSettingsFragment()
ACTION_SUGGESTIONS -> SuggestionsSettingsFragment()
ACTION_SOURCE -> SourceSettingsFragment.newInstance(
intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL
)
else -> MainSettingsFragment()
}
)
}
}
override fun onWindowInsetsChanged(insets: Insets) {
with(binding.toolbar) {
updatePadding(
left = insets.left,
right = insets.right
)
updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
}
}
companion object {
private const val ACTION_READER =
"${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
private const val ACTION_SUGGESTIONS =
"${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS"
private const val ACTION_SOURCE =
"${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
private const val EXTRA_SOURCE = "source"
fun newReaderSettingsIntent(context: Context) =
Intent(context, SimpleSettingsActivity::class.java)
.setAction(ACTION_READER)
fun newSuggestionsSettingsIntent(context: Context) =
Intent(context, SimpleSettingsActivity::class.java)
.setAction(ACTION_SUGGESTIONS)
fun newSourceSettingsIntent(context: Context, source: MangaSource) =
Intent(context, SimpleSettingsActivity::class.java)
.setAction(ACTION_SOURCE)
.putExtra(EXTRA_SOURCE, source)
}
}

View File

@@ -109,7 +109,7 @@ class PageHolderDelegate(
state = State.LOADED state = State.LOADED
callback.onImageReady(file.toUri()) callback.onImageReady(file.toUri())
} catch (e: CancellationException) { } catch (e: CancellationException) {
// do nothing throw e
} catch (e: Exception) { } catch (e: Exception) {
state = State.ERROR state = State.ERROR
error = e error = e

View File

@@ -14,7 +14,7 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.doOnCurrentItemChanged import org.koitharu.kotatsu.utils.ext.doOnCurrentItemChanged
import org.koitharu.kotatsu.utils.ext.findCenterViewPosition import org.koitharu.kotatsu.utils.ext.findCenterViewPosition
import org.koitharu.kotatsu.utils.ext.firstItem import org.koitharu.kotatsu.utils.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() { class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
@@ -52,7 +52,7 @@ class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
setItems.await() ?: return@launchWhenCreated setItems.await() ?: return@launchWhenCreated
if (position != -1) { if (position != -1) {
with(binding.recyclerView) { with(binding.recyclerView) {
firstItem = position firstVisibleItemPosition = position
post { post {
(findViewHolderForAdapterPosition(position) as? WebtoonHolder) (findViewHolderForAdapterPosition(position) as? WebtoonHolder)
?.restoreScroll(pendingState.scroll) ?.restoreScroll(pendingState.scroll)
@@ -91,6 +91,6 @@ class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
} }
override fun switchPageTo(position: Int, smooth: Boolean) { override fun switchPageTo(position: Int, smooth: Boolean) {
binding.recyclerView.firstItem = position binding.recyclerView.firstVisibleItemPosition = position
} }
} }

View File

@@ -3,13 +3,14 @@ package org.koitharu.kotatsu.remotelist.ui
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import androidx.appcompat.view.ActionMode
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.list.ui.filter.FilterBottomSheet import org.koitharu.kotatsu.list.ui.filter.FilterBottomSheet
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.utils.ext.serializableArgument import org.koitharu.kotatsu.utils.ext.serializableArgument
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
@@ -34,10 +35,7 @@ class RemoteListFragment : MangaListFragment() {
return when (item.itemId) { return when (item.itemId) {
R.id.action_source_settings -> { R.id.action_source_settings -> {
startActivity( startActivity(
SimpleSettingsActivity.newSourceSettingsIntent( SettingsActivity.newSourceSettingsIntent(context ?: return false, source)
context ?: return false,
source,
)
) )
true true
} }
@@ -49,6 +47,11 @@ class RemoteListFragment : MangaListFragment() {
} }
} }
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_remote, menu)
return super.onCreateActionMode(mode, menu)
}
override fun onFilterClick() { override fun onFilterClick() {
FilterBottomSheet.show(childFragmentManager) FilterBottomSheet.show(childFragmentManager)
} }

View File

@@ -10,6 +10,8 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTag
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.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -84,7 +86,7 @@ class MangaSearchRepository(
return when { return when {
query.isNotEmpty() && source != null -> db.tagsDao.findTags(source.name, "%$query%", limit) query.isNotEmpty() && source != null -> db.tagsDao.findTags(source.name, "%$query%", limit)
query.isNotEmpty() -> db.tagsDao.findTags("%$query%", limit) query.isNotEmpty() -> db.tagsDao.findTags("%$query%", limit)
source != null -> db.tagsDao.findTags(source.name, limit) source != null -> db.tagsDao.findPopularTags(source.name, limit)
else -> db.tagsDao.findPopularTags(limit) else -> db.tagsDao.findPopularTags(limit)
}.map { }.map {
it.toMangaTag() it.toMangaTag()

View File

@@ -1,7 +1,10 @@
package org.koitharu.kotatsu.search.ui package org.koitharu.kotatsu.search.ui
import android.view.Menu
import androidx.appcompat.view.ActionMode
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.serializableArgument import org.koitharu.kotatsu.utils.ext.serializableArgument
@@ -21,6 +24,11 @@ class SearchFragment : MangaListFragment() {
viewModel.loadNextPage() viewModel.loadNextPage()
} }
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_remote, menu)
return super.onCreateActionMode(mode, menu)
}
companion object { companion object {
private const val ARG_QUERY = "query" private const val ARG_QUERY = "query"

View File

@@ -1,12 +1,14 @@
package org.koitharu.kotatsu.search.ui.global package org.koitharu.kotatsu.search.ui.global
import android.view.Menu
import androidx.appcompat.view.ActionMode
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.utils.ext.stringArgument import org.koitharu.kotatsu.utils.ext.stringArgument
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
class GlobalSearchFragment : MangaListFragment() { class GlobalSearchFragment : MangaListFragment() {
override val viewModel by viewModel<GlobalSearchViewModel> { override val viewModel by viewModel<GlobalSearchViewModel> {
@@ -17,6 +19,11 @@ class GlobalSearchFragment : MangaListFragment() {
override fun onScrolledToEnd() = Unit override fun onScrolledToEnd() = Unit
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_remote, menu)
return super.onCreateActionMode(mode, menu)
}
companion object { companion object {
private const val ARG_QUERY = "query" private const val ARG_QUERY = "query"

View File

@@ -31,7 +31,7 @@ class SearchSuggestionItemCallback(
): Boolean = false ): Boolean = false
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val item = viewHolder.getItem<SearchSuggestionItem.RecentQuery>() ?: return val item = viewHolder.getItem(SearchSuggestionItem.RecentQuery::class.java) ?: return
listener.onRemoveQuery(item.query) listener.onRemoveQuery(item.query)
} }

View File

@@ -4,7 +4,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.areItemsEquals import org.koitharu.kotatsu.parsers.util.areItemsEquals
sealed interface SearchSuggestionItem { sealed interface SearchSuggestionItem {

View File

@@ -0,0 +1,101 @@
package org.koitharu.kotatsu.settings
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.TwoStatePreference
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
import org.koitharu.kotatsu.settings.utils.SliderPreference
import org.koitharu.kotatsu.utils.ext.names
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
import java.util.*
class AppearanceSettingsFragment :
BasePreferenceFragment(R.string.appearance),
SharedPreferences.OnSharedPreferenceChangeListener {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_appearance)
findPreference<SliderPreference>(AppSettings.KEY_GRID_SIZE)?.run {
val pattern = context.getString(R.string.percent_string_pattern)
summary = pattern.format(value.toString())
setOnPreferenceChangeListener { preference, newValue ->
preference.summary = pattern.format(newValue.toString())
true
}
}
preferenceScreen?.findPreference<ListPreference>(AppSettings.KEY_LIST_MODE)?.run {
entryValues = ListMode.values().names()
setDefaultValueCompat(ListMode.GRID.name)
}
findPreference<Preference>(AppSettings.KEY_DYNAMIC_THEME)?.isVisible = AppSettings.isDynamicColorAvailable
findPreference<ListPreference>(AppSettings.KEY_DATE_FORMAT)?.run {
entryValues = resources.getStringArray(R.array.date_formats)
val now = Date().time
entries = entryValues.map { value ->
val formattedDate = settings.getDateFormat(value.toString()).format(now)
if (value == "") {
"${context.getString(R.string.system_default)} ($formattedDate)"
} else {
formattedDate
}
}.toTypedArray()
setDefaultValueCompat("")
summary = "%s"
}
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
?.isChecked = !settings.appPassword.isNullOrEmpty()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
settings.subscribe(this)
}
override fun onDestroyView() {
settings.unsubscribe(this)
super.onDestroyView()
}
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
when (key) {
AppSettings.KEY_THEME -> {
AppCompatDelegate.setDefaultNightMode(settings.theme)
}
AppSettings.KEY_DYNAMIC_THEME -> {
findPreference<Preference>(key)?.setSummary(R.string.restart_required)
}
AppSettings.KEY_THEME_AMOLED -> {
findPreference<Preference>(key)?.setSummary(R.string.restart_required)
}
AppSettings.KEY_APP_PASSWORD -> {
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
?.isChecked = !settings.appPassword.isNullOrEmpty()
}
}
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
AppSettings.KEY_PROTECT_APP -> {
val pref = (preference as? TwoStatePreference ?: return false)
if (pref.isChecked) {
pref.isChecked = false
startActivity(Intent(preference.context, ProtectSetupActivity::class.java))
} else {
settings.appPassword = null
}
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
}

View File

@@ -0,0 +1,96 @@
package org.koitharu.kotatsu.settings
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.preference.Preference
import java.io.File
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
class ContentSettingsFragment :
BasePreferenceFragment(R.string.content),
SharedPreferences.OnSharedPreferenceChangeListener,
StorageSelectDialog.OnStorageSelectListener {
private val storageManager by inject<LocalStorageManager>()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_content)
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
)
bindRemoteSourcesSummary()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
settings.subscribe(this)
}
override fun onDestroyView() {
settings.unsubscribe(this)
super.onDestroyView()
}
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
when (key) {
AppSettings.KEY_LOCAL_STORAGE -> {
findPreference<Preference>(key)?.bindStorageName()
}
AppSettings.KEY_SUGGESTIONS -> {
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
)
}
AppSettings.KEY_SOURCES_HIDDEN -> {
bindRemoteSourcesSummary()
}
}
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
AppSettings.KEY_LOCAL_STORAGE -> {
val ctx = context ?: return false
StorageSelectDialog.Builder(ctx, storageManager, this)
.setTitle(preference.title ?: "")
.setNegativeButton(android.R.string.cancel)
.create()
.show()
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
override fun onStorageSelected(file: File) {
settings.mangaStorageDir = file
}
private fun Preference.bindStorageName() {
viewLifecycleScope.launch {
val storage = storageManager.getDefaultWriteableDir()
summary = storage?.getStorageName(context) ?: getString(R.string.not_available)
}
}
private fun bindRemoteSourcesSummary() {
findPreference<Preference>(AppSettings.KEY_REMOTE_SOURCES)?.run {
val total = MangaSource.values().size - 1
summary = getString(
R.string.enabled_d_of_d, total - settings.hiddenSources.size, total
)
}
}
}

View File

@@ -1,182 +0,0 @@
package org.koitharu.kotatsu.settings
import android.content.ComponentName
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceScreen
import androidx.preference.TwoStatePreference
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
import org.koitharu.kotatsu.settings.utils.SliderPreference
import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.names
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import java.io.File
import java.util.*
class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
SharedPreferences.OnSharedPreferenceChangeListener,
StorageSelectDialog.OnStorageSelectListener {
private val storageManager by inject<LocalStorageManager>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_main)
findPreference<SliderPreference>(AppSettings.KEY_GRID_SIZE)?.run {
summary = "%d%%".format(value)
setOnPreferenceChangeListener { preference, newValue ->
preference.summary = "%d%%".format(newValue)
true
}
}
preferenceScreen?.findPreference<ListPreference>(AppSettings.KEY_LIST_MODE)?.run {
entryValues = ListMode.values().names()
setDefaultValueCompat(ListMode.GRID.name)
}
findPreference<Preference>(AppSettings.KEY_DYNAMIC_THEME)?.isVisible =
AppSettings.isDynamicColorAvailable
findPreference<ListPreference>(AppSettings.KEY_DATE_FORMAT)?.run {
entryValues = resources.getStringArray(R.array.date_formats)
val now = Date().time
entries = entryValues.map { value ->
val formattedDate = settings.getDateFormat(value.toString()).format(now)
if (value == "") {
"${context.getString(R.string.system_default)} ($formattedDate)"
} else {
formattedDate
}
}.toTypedArray()
setDefaultValueCompat("")
summary = "%s"
}
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)?.isChecked =
!settings.appPassword.isNullOrEmpty()
settings.subscribe(this)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.opt_settings, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_leaks -> {
val intent = Intent()
intent.component = ComponentName(requireContext(), "leakcanary.internal.activity.LeakActivity")
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
startActivity(intent)
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onDestroyView() {
settings.unsubscribe(this)
super.onDestroyView()
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
when (key) {
AppSettings.KEY_THEME -> {
AppCompatDelegate.setDefaultNightMode(settings.theme)
}
AppSettings.KEY_DYNAMIC_THEME -> {
findPreference<Preference>(key)?.setSummary(R.string.restart_required)
}
AppSettings.KEY_THEME_AMOLED -> {
findPreference<Preference>(key)?.setSummary(R.string.restart_required)
}
AppSettings.KEY_LOCAL_STORAGE -> {
findPreference<Preference>(key)?.bindStorageName()
}
AppSettings.KEY_APP_PASSWORD -> {
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
?.isChecked = !settings.appPassword.isNullOrEmpty()
}
AppSettings.KEY_SUGGESTIONS -> {
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
)
}
}
}
override fun onResume() {
super.onResume()
findPreference<PreferenceScreen>(AppSettings.KEY_REMOTE_SOURCES)?.run {
val total = MangaSource.values().size - 1
summary = getString(
R.string.enabled_d_of_d, total - settings.hiddenSources.size, total
)
}
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
AppSettings.KEY_LOCAL_STORAGE -> {
val ctx = context ?: return false
StorageSelectDialog.Builder(ctx, storageManager, this)
.setTitle(preference.title ?: "")
.setNegativeButton(android.R.string.cancel)
.create()
.show()
true
}
AppSettings.KEY_PROTECT_APP -> {
val pref = (preference as? TwoStatePreference ?: return false)
if (pref.isChecked) {
pref.isChecked = false
startActivity(Intent(preference.context, ProtectSetupActivity::class.java))
} else {
settings.appPassword = null
}
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
override fun onStorageSelected(file: File) {
settings.mangaStorageDir = file
}
private fun Preference.bindStorageName() {
viewLifecycleScope.launch {
val storage = storageManager.getDefaultWriteableDir()
summary = storage?.getStorageName(context) ?: getString(R.string.not_available)
}
}
}

View File

@@ -4,9 +4,9 @@ import android.os.Bundle
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
class NetworkSettingsFragment : BasePreferenceFragment(R.string.settings) { class RootSettingsFragment : BasePreferenceFragment(R.string.settings) {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
//TODO https://developer.android.com/training/basics/network-ops/managing addPreferencesFromResource(R.xml.pref_root)
} }
} }

View File

@@ -1,9 +1,13 @@
package org.koitharu.kotatsu.settings package org.koitharu.kotatsu.settings
import android.content.ComponentName
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 androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.FragmentTransaction
@@ -11,6 +15,7 @@ import androidx.fragment.app.commit
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
@@ -34,9 +39,7 @@ class SettingsActivity :
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (supportFragmentManager.findFragmentById(R.id.container) == null) { if (supportFragmentManager.findFragmentById(R.id.container) == null) {
supportFragmentManager.commit { openDefaultFragment()
replace(R.id.container, MainSettingsFragment())
}
} }
} }
@@ -55,6 +58,22 @@ class SettingsActivity :
super.onStop() super.onStop()
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_settings, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.action_leaks -> {
val intent = Intent()
intent.component = ComponentName(this, "leakcanary.internal.activity.LeakActivity")
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
startActivity(intent)
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onBackStackChanged() { override fun onBackStackChanged() {
val fragment = supportFragmentManager.findFragmentById(R.id.container) as? RecyclerViewOwner ?: return val fragment = supportFragmentManager.findFragmentById(R.id.container) as? RecyclerViewOwner ?: return
val recyclerView = fragment.recyclerView val recyclerView = fragment.recyclerView
@@ -70,32 +89,66 @@ class SettingsActivity :
val fm = supportFragmentManager val fm = supportFragmentManager
val fragment = fm.fragmentFactory.instantiate(classLoader, pref.fragment ?: return false) val fragment = fm.fragmentFactory.instantiate(classLoader, pref.fragment ?: return false)
fragment.arguments = pref.extras fragment.arguments = pref.extras
fragment.setTargetFragment(caller, 0) // fragment.setTargetFragment(caller, 0)
openFragment(fragment) openFragment(fragment)
return true return true
} }
fun openMangaSourceSettings(mangaSource: MangaSource) { override fun onWindowInsetsChanged(insets: Insets) {
openFragment(SourceSettingsFragment.newInstance(mangaSource)) binding.appbar.updatePadding(
left = insets.left,
right = insets.right,
)
binding.container.updatePadding(
left = insets.left,
right = insets.right,
)
} }
fun openNotificationSettingsLegacy() { fun openFragment(fragment: Fragment) {
openFragment(NotificationSettingsLegacyFragment())
}
private fun openFragment(fragment: Fragment) {
supportFragmentManager.commit { supportFragmentManager.commit {
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN)
replace(R.id.container, fragment)
setReorderingAllowed(true) setReorderingAllowed(true)
replace(R.id.container, fragment)
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
addToBackStack(null) addToBackStack(null)
} }
} }
override fun onWindowInsetsChanged(insets: Insets) = Unit private fun openDefaultFragment() {
val fragment = when (intent?.action) {
ACTION_READER -> ReaderSettingsFragment()
ACTION_SUGGESTIONS -> SuggestionsSettingsFragment()
ACTION_SOURCE -> SourceSettingsFragment.newInstance(
intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL
)
else -> SettingsHeadersFragment()
}
supportFragmentManager.commit {
setReorderingAllowed(true)
replace(R.id.container, fragment)
}
}
companion object { companion object {
private const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
private const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS"
private const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
private const val EXTRA_SOURCE = "source"
fun newIntent(context: Context) = Intent(context, SettingsActivity::class.java) fun newIntent(context: Context) = Intent(context, SettingsActivity::class.java)
fun newReaderSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_READER)
fun newSuggestionsSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_SUGGESTIONS)
fun newSourceSettingsIntent(context: Context, source: MangaSource) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_SOURCE)
.putExtra(EXTRA_SOURCE, source)
} }
} }

View File

@@ -0,0 +1,49 @@
package org.koitharu.kotatsu.settings
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceHeaderFragmentCompat
import androidx.slidingpanelayout.widget.SlidingPaneLayout
import org.koitharu.kotatsu.R
class SettingsHeadersFragment : PreferenceHeaderFragmentCompat(), SlidingPaneLayout.PanelSlideListener {
private var currentTitle: CharSequence? = null
override fun onCreatePreferenceHeader(): PreferenceFragmentCompat = RootSettingsFragment()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
slidingPaneLayout.addPanelSlideListener(this)
}
override fun onPanelSlide(panel: View, slideOffset: Float) = Unit
override fun onPanelOpened(panel: View) {
activity?.title = currentTitle ?: getString(R.string.settings)
}
override fun onPanelClosed(panel: View) {
activity?.setTitle(R.string.settings)
}
fun setTitle(title: CharSequence?) {
currentTitle = title
if (slidingPaneLayout.isSlideable && slidingPaneLayout.isOpen) {
activity?.title = title
}
}
fun openFragment(fragment: Fragment) {
childFragmentManager.commit {
setReorderingAllowed(true)
replace(androidx.preference.R.id.preferences_detail, fragment)
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
addToBackStack(null)
}
}
}

View File

@@ -18,7 +18,7 @@ val settingsModule
single { BackupRepository(get()) } single { BackupRepository(get()) }
single { RestoreRepository(get()) } single { RestoreRepository(get()) }
single { AppSettings(androidContext()) } single(createdAtStart = true) { AppSettings(androidContext()) }
viewModel { BackupViewModel(get(), androidContext()) } viewModel { BackupViewModel(get(), androidContext()) }
viewModel { params -> viewModel { params ->

View File

@@ -25,7 +25,7 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
activity?.title = source.title setTitle(source.title)
} }
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {

View File

@@ -4,10 +4,13 @@ import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.utils.MultiAutoCompleteTextViewPreference
import org.koitharu.kotatsu.settings.utils.TagsAutoCompleteProvider
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
@@ -23,6 +26,11 @@ class SuggestionsSettingsFragment : BasePreferenceFragment(R.string.suggestions)
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_suggestions) addPreferencesFromResource(R.xml.pref_suggestions)
findPreference<MultiAutoCompleteTextViewPreference>(AppSettings.KEY_SUGGESTIONS_EXCLUDE_TAGS)?.run {
autoCompleteProvider = TagsAutoCompleteProvider(get())
summaryProvider = MultiAutoCompleteTextViewPreference.SimpleSummaryProvider(summary)
}
} }
override fun onDestroy() { override fun onDestroy() {

View File

@@ -31,7 +31,6 @@ class TrackerSettingsFragment : BasePreferenceFragment(R.string.check_for_new_ch
append(getString(R.string.read_more)) append(getString(R.string.read_more))
} }
} }
warningPreference
} }
} }
@@ -43,10 +42,10 @@ class TrackerSettingsFragment : BasePreferenceFragment(R.string.check_for_new_ch
.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName) .putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName)
.putExtra(Settings.EXTRA_CHANNEL_ID, TrackWorker.CHANNEL_ID) .putExtra(Settings.EXTRA_CHANNEL_ID, TrackWorker.CHANNEL_ID)
startActivity(intent) startActivity(intent)
true
} else { } else {
(activity as? SettingsActivity)?.openNotificationSettingsLegacy() super.onPreferenceTreeClick(preference)
} }
true
} }
else -> super.onPreferenceTreeClick(preference) else -> super.onPreferenceTreeClick(preference)
} }

View File

@@ -1,13 +1,13 @@
package org.koitharu.kotatsu.settings.about package org.koitharu.kotatsu.settings.about
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View import androidx.core.net.toUri
import androidx.preference.Preference import androidx.preference.Preference
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.AppUpdateChecker import org.koitharu.kotatsu.settings.AppUpdateChecker
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
@@ -16,20 +16,16 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_about) addPreferencesFromResource(R.xml.pref_about)
} val isUpdateSupported = AppUpdateChecker.isUpdateSupported(requireContext())
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_APP_UPDATE_AUTO)?.run { findPreference<Preference>(AppSettings.KEY_APP_UPDATE_AUTO)?.run {
isVisible = AppUpdateChecker.isUpdateSupported(context) isVisible = isUpdateSupported
} }
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run { findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
title = getString(R.string.app_version, BuildConfig.VERSION_NAME) title = getString(R.string.app_version, BuildConfig.VERSION_NAME)
isEnabled = AppUpdateChecker.isUpdateSupported(context) isEnabled = isUpdateSupported
} }
} }
override fun onPreferenceTreeClick(preference: Preference): Boolean { override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) { return when (preference.key) {
AppSettings.KEY_APP_VERSION -> { AppSettings.KEY_APP_VERSION -> {
@@ -37,39 +33,19 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
true true
} }
AppSettings.KEY_APP_TRANSLATION -> { AppSettings.KEY_APP_TRANSLATION -> {
startActivity(context?.let { BrowserActivity.newIntent(it, openLink(getString(R.string.url_weblate), preference.title)
"https://hosted.weblate.org/engage/kotatsu",
resources.getString(R.string.about_app_translation)) })
true true
} }
AppSettings.KEY_FEEDBACK_4PDA -> { AppSettings.KEY_FEEDBACK_4PDA -> {
startActivity(context?.let { BrowserActivity.newIntent(it, openLink(getString(R.string.url_forpda), preference.title)
"https://4pda.to/forum/index.php?showtopic=697669",
resources.getString(R.string.about_feedback_4pda)) })
true true
} }
AppSettings.KEY_FEEDBACK_DISCORD -> { AppSettings.KEY_FEEDBACK_DISCORD -> {
startActivity(context?.let { BrowserActivity.newIntent(it, openLink(getString(R.string.url_discord), preference.title)
"https://discord.gg/NNJ5RgVBC5",
"Discord") })
true true
} }
AppSettings.KEY_FEEDBACK_GITHUB -> { AppSettings.KEY_FEEDBACK_GITHUB -> {
startActivity(context?.let { BrowserActivity.newIntent(it, openLink(getString(R.string.url_github_issues), preference.title)
"https://github.com/nv95/Kotatsu/issues",
"GitHub") })
true
}
AppSettings.KEY_SUPPORT_DEVELOPER -> {
startActivity(context?.let { BrowserActivity.newIntent(it,
"https://yoomoney.ru/to/410012543938752",
resources.getString(R.string.about_support_developer)) })
true
}
AppSettings.KEY_APP_GRATITUDES -> {
startActivity(context?.let { BrowserActivity.newIntent(it,
"https://github.com/nv95/Kotatsu/graphs/contributors",
resources.getString(R.string.about_gratitudes)) })
true true
} }
else -> super.onPreferenceTreeClick(preference) else -> super.onPreferenceTreeClick(preference)
@@ -95,4 +71,16 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
} }
} }
} }
private fun openLink(url: String, title: CharSequence?) {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = url.toUri()
startActivity(
if (title != null) {
Intent.createChooser(intent, title)
} else {
intent
}
)
}
} }

View File

@@ -1,41 +0,0 @@
package org.koitharu.kotatsu.settings.about
import android.os.Bundle
import android.text.SpannableStringBuilder
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.Insets
import androidx.core.text.HtmlCompat
import androidx.core.text.parseAsHtml
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.databinding.FragmentCopyrightBinding
class LicenseFragment : BaseFragment<FragmentCopyrightBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.textView.apply {
text =
SpannableStringBuilder(resources.openRawResource(R.raw.copyright).bufferedReader()
.readText()
.parseAsHtml(HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_LIST))
movementMethod = LinkMovementMethod.getInstance()
}
}
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
) = FragmentCopyrightBinding.inflate(inflater, container, false)
override fun onResume() {
super.onResume()
activity?.setTitle(R.string.about_license)
}
override fun onWindowInsetsChanged(insets: Insets) = Unit
}

View File

@@ -0,0 +1,107 @@
package org.koitharu.kotatsu.settings.backup
import android.app.backup.BackupAgent
import android.app.backup.BackupDataInput
import android.app.backup.BackupDataOutput
import android.app.backup.FullBackupDataOutput
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.core.backup.BackupArchive
import org.koitharu.kotatsu.core.backup.BackupEntry
import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.backup.RestoreRepository
import org.koitharu.kotatsu.core.db.MangaDatabase
import java.io.*
class AppBackupAgent : BackupAgent() {
override fun onBackup(
oldState: ParcelFileDescriptor?,
data: BackupDataOutput?,
newState: ParcelFileDescriptor?
) = Unit
override fun onRestore(
data: BackupDataInput?,
appVersionCode: Int,
newState: ParcelFileDescriptor?
) = Unit
override fun onFullBackup(data: FullBackupDataOutput) {
super.onFullBackup(data)
val file = createBackupFile()
try {
fullBackupFile(file, data)
} finally {
file.delete()
}
}
override fun onRestoreFile(
data: ParcelFileDescriptor,
size: Long,
destination: File?,
type: Int,
mode: Long,
mtime: Long
) {
if (destination?.name?.endsWith(".bak") == true) {
restoreBackupFile(data.fileDescriptor, size)
destination.delete()
} else {
super.onRestoreFile(data, size, destination, type, mode, mtime)
}
}
private fun createBackupFile() = runBlocking {
val repository = BackupRepository(MangaDatabase.create(applicationContext))
val backup = BackupArchive.createNew(this@AppBackupAgent)
backup.put(repository.createIndex())
backup.put(repository.dumpHistory())
backup.put(repository.dumpCategories())
backup.put(repository.dumpFavourites())
backup.flush()
backup.cleanup()
backup.file
}
private fun restoreBackupFile(fd: FileDescriptor, size: Long) {
val repository = RestoreRepository(MangaDatabase.create(applicationContext))
val tempFile = File.createTempFile("backup_", ".tmp")
FileInputStream(fd).use { input ->
tempFile.outputStream().use { output ->
input.copyLimitedTo(output, size)
}
}
val backup = BackupArchive(tempFile)
try {
runBlocking {
backup.unpack()
repository.upsertHistory(backup.getEntry(BackupEntry.HISTORY))
repository.upsertCategories(backup.getEntry(BackupEntry.CATEGORIES))
repository.upsertFavourites(backup.getEntry(BackupEntry.FAVOURITES))
}
} finally {
runBlocking(NonCancellable) {
backup.cleanup()
}
tempFile.delete()
}
}
private fun InputStream.copyLimitedTo(out: OutputStream, limit: Long) {
var bytesCopied: Long = 0
val buffer = ByteArray(DEFAULT_BUFFER_SIZE.coerceAtMost(limit.toInt()))
var bytes = read(buffer)
while (bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
val bytesLeft = (limit - bytesCopied).toInt()
if (bytesLeft <= 0) {
break
}
bytes = read(buffer, 0, buffer.size.coerceAtMost(bytesLeft))
}
}
}

View File

@@ -6,6 +6,7 @@ import androidx.lifecycle.MutableLiveData
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
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 org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
import org.koitharu.kotatsu.utils.ext.map import org.koitharu.kotatsu.utils.ext.map

View File

@@ -15,6 +15,8 @@ import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding
import org.koitharu.kotatsu.main.ui.AppBarOwner import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.SettingsHeadersFragment
import org.koitharu.kotatsu.settings.SourceSettingsFragment
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
@@ -87,7 +89,9 @@ class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(),
} }
override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) { override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) {
(activity as? SettingsActivity)?.openMangaSourceSettings(item.source) val fragment = SourceSettingsFragment.newInstance(item.source)
(parentFragment as? SettingsHeadersFragment)?.openFragment(fragment)
?: (activity as? SettingsActivity)?.openFragment(fragment)
} }
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {

View File

@@ -0,0 +1,118 @@
package org.koitharu.kotatsu.settings.utils
import android.content.Context
import android.util.AttributeSet
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.Filter
import android.widget.MultiAutoCompleteTextView
import androidx.annotation.AttrRes
import androidx.annotation.MainThread
import androidx.annotation.StyleRes
import androidx.annotation.WorkerThread
import androidx.preference.EditTextPreference
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.replaceWith
class MultiAutoCompleteTextViewPreference @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = R.attr.multiAutoCompleteTextViewPreferenceStyle,
@StyleRes defStyleRes: Int = R.style.Preference_MultiAutoCompleteTextView,
) : EditTextPreference(context, attrs, defStyleAttr, defStyleRes) {
private val autoCompleteBindListener = AutoCompleteBindListener()
var autoCompleteProvider: AutoCompleteProvider? = null
init {
super.setOnBindEditTextListener(autoCompleteBindListener)
}
override fun setOnBindEditTextListener(onBindEditTextListener: OnBindEditTextListener?) {
autoCompleteBindListener.delegate = onBindEditTextListener
}
private inner class AutoCompleteBindListener : OnBindEditTextListener {
var delegate: OnBindEditTextListener? = null
override fun onBindEditText(editText: EditText) {
delegate?.onBindEditText(editText)
if (editText !is MultiAutoCompleteTextView) {
return
}
editText.setTokenizer(MultiAutoCompleteTextView.CommaTokenizer())
editText.setAdapter(
autoCompleteProvider?.let {
CompletionAdapter(editText.context, it, ArrayList())
}
)
editText.threshold = 1
}
}
interface AutoCompleteProvider {
suspend fun getSuggestions(query: String): List<String>
}
class SimpleSummaryProvider(
private val emptySummary: CharSequence?,
) : SummaryProvider<MultiAutoCompleteTextViewPreference> {
override fun provideSummary(preference: MultiAutoCompleteTextViewPreference): CharSequence? {
return if (preference.text.isNullOrEmpty()) {
emptySummary
} else {
preference.text?.trimEnd(' ', ',')
}
}
}
private class CompletionAdapter(
context: Context,
private val completionProvider: AutoCompleteProvider,
private val dataset: MutableList<String>,
) : ArrayAdapter<String>(context, android.R.layout.simple_dropdown_item_1line, dataset) {
override fun getFilter(): Filter {
return CompletionFilter(this, completionProvider)
}
fun publishResults(results: List<String>) {
dataset.replaceWith(results)
notifyDataSetChanged()
}
}
private class CompletionFilter(
private val adapter: CompletionAdapter,
private val provider: AutoCompleteProvider,
) : Filter() {
@WorkerThread
override fun performFiltering(constraint: CharSequence?): FilterResults {
val query = constraint?.toString().orEmpty()
val suggestions = runBlocking { provider.getSuggestions(query) }
return CompletionResults(suggestions)
}
@MainThread
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
val completions = (results as CompletionResults).completions
adapter.publishResults(completions)
}
private class CompletionResults(
val completions: List<String>,
) : FilterResults() {
init {
values = completions
count = completions.size
}
}
}
}

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