Compare commits
114 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34f6e5232b | ||
|
|
f205c1b3dc | ||
|
|
4b2a487c37 | ||
|
|
726ac21974 | ||
|
|
6b35216949 | ||
|
|
22cae62f17 | ||
|
|
4733caf2e6 | ||
|
|
d49103de1f | ||
|
|
414bab7ce3 | ||
|
|
64c1873eb5 | ||
|
|
06a0b5829b | ||
|
|
0ce2870c8b | ||
|
|
f59027666b | ||
|
|
8513bc6daf | ||
|
|
cceaefc896 | ||
|
|
1d32f53bdd | ||
|
|
0e98dd8695 | ||
|
|
119b7c2ac7 | ||
|
|
5701862661 | ||
|
|
5590ab7c8a | ||
|
|
9fde0106be | ||
|
|
e73f077dc5 | ||
|
|
c37458d43a | ||
|
|
e2fcfcc7a8 | ||
|
|
7a3b2a9bb4 | ||
|
|
881f154b5e | ||
|
|
34be5d16f2 | ||
|
|
e7e554648d | ||
|
|
89a4180b46 | ||
|
|
4e2e190547 | ||
|
|
3c557aae6c | ||
|
|
0b00a3675d | ||
|
|
8f20be6953 | ||
|
|
26875c01c6 | ||
|
|
4beb34c1a5 | ||
|
|
1d50ab00c4 | ||
|
|
299cd229ec | ||
|
|
b02f394cd4 | ||
|
|
7352f06564 | ||
|
|
1e4861367e | ||
|
|
bc3208946b | ||
|
|
d5fbb00676 | ||
|
|
7514362ca4 | ||
|
|
e76a04bea0 | ||
|
|
732a6e7c26 | ||
|
|
f3111dc636 | ||
|
|
e0e0cf4ecd | ||
|
|
50f302a7f8 | ||
|
|
500995a9d8 | ||
|
|
beaf5cc0d5 | ||
|
|
6377de470d | ||
|
|
dec45f7851 | ||
|
|
dbada34a43 | ||
|
|
b62467964e | ||
|
|
3249e10931 | ||
|
|
0d5229b112 | ||
|
|
d0ed1fb85f | ||
|
|
9e5664da3a | ||
|
|
35c158d35a | ||
|
|
464f24e9f0 | ||
|
|
c8a8203c39 | ||
|
|
b414758f32 | ||
|
|
1181860e41 | ||
|
|
e35521f16f | ||
|
|
5fb8ff53f9 | ||
|
|
a66283d035 | ||
|
|
a1ba0b8c21 | ||
|
|
f3b42b9a42 | ||
|
|
aa2f2c17fc | ||
|
|
ebc17b645b | ||
|
|
cc14e1abcf | ||
|
|
b1b474e2e7 | ||
|
|
8ca3bece5d | ||
|
|
90bd9023d5 | ||
|
|
986627f24d | ||
|
|
cf2b8e2481 | ||
|
|
b9435de5cd | ||
|
|
861c21faea | ||
|
|
9b4d014b21 | ||
|
|
c6da7de699 | ||
|
|
ef3aa40acc | ||
|
|
07af3ea703 | ||
|
|
391c8ab649 | ||
|
|
6b1885c89d | ||
|
|
8423b48fb9 | ||
|
|
803c825d91 | ||
|
|
6a9682a077 | ||
|
|
9197b9cc3a | ||
|
|
02ea804874 | ||
|
|
c424466198 | ||
|
|
18b312dde6 | ||
|
|
f78262b1a0 | ||
|
|
c557a51c4d | ||
|
|
8995762935 | ||
|
|
ed2664db78 | ||
|
|
f5a5e53b5a | ||
|
|
9ef961590d | ||
|
|
9b569615ee | ||
|
|
f48cf2efe4 | ||
|
|
18094a310c | ||
|
|
320c49a831 | ||
|
|
2a971d5dae | ||
|
|
4467e79ae6 | ||
|
|
c68b180bf6 | ||
|
|
5f879f6c83 | ||
|
|
aeb3732d75 | ||
|
|
6292a0fd6b | ||
|
|
8985b4135d | ||
|
|
f8a5397542 | ||
|
|
5f51041220 | ||
|
|
5a14412b62 | ||
|
|
be012f631a | ||
|
|
0165f43603 | ||
|
|
1d1e49123a |
@@ -4,7 +4,7 @@ root = true
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = tab
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
max_line_length = 120
|
||||
tab_width = 4
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,6 +6,7 @@
|
||||
/.idea/dictionaries
|
||||
/.idea/modules.xml
|
||||
/.idea/misc.xml
|
||||
/.idea/markdown.xml
|
||||
/.idea/discord.xml
|
||||
/.idea/compiler.xml
|
||||
/.idea/workspace.xml
|
||||
@@ -26,4 +27,4 @@
|
||||
.cxx
|
||||
/.idea/deviceManager.xml
|
||||
/.kotlin/
|
||||
/.idea/AndroidProjectSystem.xml
|
||||
/.idea/AndroidProjectSystem.xml
|
||||
|
||||
74
.idea/codeStyles/Project.xml
generated
74
.idea/codeStyles/Project.xml
generated
@@ -1,9 +1,7 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<option name="OTHER_INDENT_OPTIONS">
|
||||
<value>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</value>
|
||||
<value />
|
||||
</option>
|
||||
<AndroidXmlCodeStyleSettings>
|
||||
<option name="LAYOUT_SETTINGS">
|
||||
@@ -22,40 +20,46 @@
|
||||
</value>
|
||||
</option>
|
||||
</AndroidXmlCodeStyleSettings>
|
||||
<JavaCodeStyleSettings>
|
||||
<option name="IMPORT_LAYOUT_TABLE">
|
||||
<value>
|
||||
<package name="android" withSubpackages="true" static="true" />
|
||||
<package name="androidx" withSubpackages="true" static="true" />
|
||||
<package name="com" withSubpackages="true" static="true" />
|
||||
<package name="junit" withSubpackages="true" static="true" />
|
||||
<package name="net" withSubpackages="true" static="true" />
|
||||
<package name="org" withSubpackages="true" static="true" />
|
||||
<package name="java" withSubpackages="true" static="true" />
|
||||
<package name="javax" withSubpackages="true" static="true" />
|
||||
<package name="" withSubpackages="true" static="true" />
|
||||
<emptyLine />
|
||||
<package name="android" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="androidx" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="com" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="junit" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="net" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="org" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="java" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="javax" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
</value>
|
||||
</option>
|
||||
</JavaCodeStyleSettings>
|
||||
<JetCodeStyleSettings>
|
||||
<option name="ALLOW_TRAILING_COMMA" value="true" />
|
||||
<option name="ALLOW_TRAILING_COMMA_COLLECTION_LITERAL_EXPRESSION" value="true" />
|
||||
<option name="ALLOW_TRAILING_COMMA_VALUE_ARGUMENT_LIST" value="true" />
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="CMake">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="Groovy">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="HTML">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="JAVA">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="JSON">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="ObjectiveC">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="Shell Script">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
@@ -64,7 +68,6 @@
|
||||
<codeStyleSettings language="XML">
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
@@ -179,9 +182,6 @@
|
||||
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
|
||||
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
|
||||
<option name="LINE_COMMENT_ADD_SPACE" value="true" />
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
4
.idea/gradle.xml
generated
4
.idea/gradle.xml
generated
@@ -6,7 +6,7 @@
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="jbr-21" />
|
||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
@@ -16,4 +16,4 @@
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
</project>
|
||||
|
||||
42
README.md
42
README.md
@@ -1,24 +1,16 @@
|
||||
> [!IMPORTANT]
|
||||
> In light of recent challenges — including threating actions from Kakao Entertainment Corp and upcoming Google’s
|
||||
> [new sideloading policy](https://f-droid.org/ru/2025/10/28/sideloading.html) — we’ve made the difficult decision to shut down Kotatsu and end its support. We’re deeply grateful
|
||||
> to everyone who contributed and to the amazing community that grew around this project.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://kotatsu.app">
|
||||
<img src="./.github/assets/vtuber.png" alt="Kotatsu Logo" title="Kotatsu" width="600"/>
|
||||
</a>
|
||||
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in
|
||||
online content sources.**
|
||||
|
||||
# [Kotatsu](https://kotatsu.app)
|
||||
|
||||
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in online content sources.**
|
||||
|
||||
   [](https://github.com/KotatsuApp/kotatsu-parsers) [](https://hosted.weblate.org/engage/kotatsu/) [](https://discord.gg/NNJ5RgVBC5) [](https://t.me/kotatsuapp) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||
|
||||
### Download
|
||||
|
||||
<div align="left">
|
||||
|
||||
* **Recommended:** Download and install APK from [GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest). Application has a built-in self-updating feature.
|
||||
* Get it on [F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu). The F-Droid build may be a bit outdated and some fixes might be missing.
|
||||
* Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (Unstable, use at your own risk). Application has a built-in self-updating feature.
|
||||
|
||||
</div>
|
||||
 [](https://github.com/KotatsuApp/kotatsu-parsers) [](https://hosted.weblate.org/engage/kotatsu/) [](https://discord.gg/NNJ5RgVBC5) [](https://t.me/kotatsuapp) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||
|
||||
### Main Features
|
||||
|
||||
@@ -35,7 +27,7 @@
|
||||
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
||||
* Password / fingerprint-protected access to the app
|
||||
* Automatically sync app data with other devices on the same account
|
||||
* Support for older devices running Android 5.0+
|
||||
* Support for older devices running Android 6.0+
|
||||
|
||||
</div>
|
||||
|
||||
@@ -86,7 +78,8 @@ please head over to the [Weblate project page](https://hosted.weblate.org/engage
|
||||
|
||||
</br>
|
||||
|
||||
**📌 Pull requests are welcome, if you want: See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
|
||||
**📌 Pull requests are welcome, if you want:
|
||||
See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
|
||||
|
||||
### Certificate fingerprints
|
||||
|
||||
@@ -104,7 +97,9 @@ please head over to the [Weblate project page](https://hosted.weblate.org/engage
|
||||
|
||||
<div align="left">
|
||||
|
||||
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions.
|
||||
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications
|
||||
to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build &
|
||||
install instructions.
|
||||
|
||||
</div>
|
||||
|
||||
@@ -112,6 +107,9 @@ You may copy, distribute and modify the software as long as you track changes/da
|
||||
|
||||
<div align="left">
|
||||
|
||||
The developers of this application do not have any affiliation with the content available in the app. It collects content from sources that are freely available through any web browser.
|
||||
The developers of this application do not have any affiliation with the content available in the app and does not store
|
||||
or distribute any content. This application should be considered a web browser, all content that can be found using this
|
||||
application is freely available on the Internet. All DMCA takedown requests should be sent to the owners of the website
|
||||
where the content is hosted.
|
||||
|
||||
</div>
|
||||
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 23
|
||||
targetSdk = 36
|
||||
versionCode = 1029
|
||||
versionName = '9.2-b1'
|
||||
versionCode = 1033
|
||||
versionName = '9.4.1'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
@@ -155,6 +155,9 @@ dependencies {
|
||||
implementation libs.androidx.work.runtime
|
||||
implementation libs.guava
|
||||
|
||||
// Foldable/Window layout
|
||||
implementation libs.androidx.window
|
||||
|
||||
implementation libs.androidx.room.runtime
|
||||
implementation libs.androidx.room.ktx
|
||||
ksp libs.androidx.room.compiler
|
||||
|
||||
4
app/proguard-rules.pro
vendored
4
app/proguard-rules.pro
vendored
@@ -16,8 +16,10 @@
|
||||
-dontwarn com.google.j2objc.annotations.**
|
||||
-dontwarn coil3.PlatformContext
|
||||
|
||||
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
||||
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
||||
-keep class org.koitharu.kotatsu.settings.about.changelog.ChangelogFragment
|
||||
|
||||
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
||||
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
|
||||
-keep class org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment { *; }
|
||||
-keep class org.jsoup.parser.Tag
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
android:fullBackupContent="@xml/backup_content"
|
||||
android:fullBackupOnly="true"
|
||||
android:hasFragileUserData="true"
|
||||
android:restoreAnyVersion="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
|
||||
@@ -26,12 +26,17 @@ import org.koitharu.kotatsu.backups.data.model.CategoryBackup
|
||||
import org.koitharu.kotatsu.backups.data.model.FavouriteBackup
|
||||
import org.koitharu.kotatsu.backups.data.model.HistoryBackup
|
||||
import org.koitharu.kotatsu.backups.data.model.MangaBackup
|
||||
import org.koitharu.kotatsu.backups.data.model.ScrobblingBackup
|
||||
import org.koitharu.kotatsu.backups.data.model.SourceBackup
|
||||
import org.koitharu.kotatsu.backups.data.model.StatisticBackup
|
||||
import org.koitharu.kotatsu.backups.domain.BackupSection
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.CompositeResult
|
||||
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.filter.data.PersistableFilter
|
||||
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
||||
import java.io.InputStream
|
||||
@@ -43,220 +48,267 @@ import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class BackupRepository @Inject constructor(
|
||||
private val database: MangaDatabase,
|
||||
private val settings: AppSettings,
|
||||
private val tapGridSettings: TapGridSettings,
|
||||
private val database: MangaDatabase,
|
||||
private val settings: AppSettings,
|
||||
private val tapGridSettings: TapGridSettings,
|
||||
private val mangaSourcesRepository: MangaSourcesRepository,
|
||||
private val savedFiltersRepository: SavedFiltersRepository,
|
||||
) {
|
||||
|
||||
private val json = Json {
|
||||
allowSpecialFloatingPointValues = true
|
||||
coerceInputValues = true
|
||||
encodeDefaults = true
|
||||
ignoreUnknownKeys = true
|
||||
useAlternativeNames = false
|
||||
}
|
||||
private val json = Json {
|
||||
allowSpecialFloatingPointValues = true
|
||||
coerceInputValues = true
|
||||
encodeDefaults = true
|
||||
ignoreUnknownKeys = true
|
||||
useAlternativeNames = false
|
||||
}
|
||||
|
||||
suspend fun createBackup(
|
||||
output: ZipOutputStream,
|
||||
progress: FlowCollector<Progress>?,
|
||||
) {
|
||||
progress?.emit(Progress.INDETERMINATE)
|
||||
var commonProgress = Progress(0, BackupSection.entries.size)
|
||||
for (section in BackupSection.entries) {
|
||||
when (section) {
|
||||
BackupSection.INDEX -> output.writeJsonArray(
|
||||
section = BackupSection.INDEX,
|
||||
data = flowOf(BackupIndex()),
|
||||
serializer = serializer(),
|
||||
)
|
||||
suspend fun createBackup(
|
||||
output: ZipOutputStream,
|
||||
progress: FlowCollector<Progress>?,
|
||||
) {
|
||||
progress?.emit(Progress.INDETERMINATE)
|
||||
var commonProgress = Progress(0, BackupSection.entries.size)
|
||||
for (section in BackupSection.entries) {
|
||||
when (section) {
|
||||
BackupSection.INDEX -> output.writeJsonArray(
|
||||
section = BackupSection.INDEX,
|
||||
data = flowOf(BackupIndex()),
|
||||
serializer = serializer(),
|
||||
)
|
||||
|
||||
BackupSection.HISTORY -> output.writeJsonArray(
|
||||
section = BackupSection.HISTORY,
|
||||
data = database.getHistoryDao().dump().map { HistoryBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
BackupSection.HISTORY -> output.writeJsonArray(
|
||||
section = BackupSection.HISTORY,
|
||||
data = database.getHistoryDao().dump().map { HistoryBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
|
||||
BackupSection.CATEGORIES -> output.writeJsonArray(
|
||||
section = BackupSection.CATEGORIES,
|
||||
data = database.getFavouriteCategoriesDao().findAll().asFlow().map { CategoryBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
BackupSection.CATEGORIES -> output.writeJsonArray(
|
||||
section = BackupSection.CATEGORIES,
|
||||
data = database.getFavouriteCategoriesDao().findAll().asFlow().map { CategoryBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
|
||||
BackupSection.FAVOURITES -> output.writeJsonArray(
|
||||
section = BackupSection.FAVOURITES,
|
||||
data = database.getFavouritesDao().dump().map { FavouriteBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
BackupSection.FAVOURITES -> output.writeJsonArray(
|
||||
section = BackupSection.FAVOURITES,
|
||||
data = database.getFavouritesDao().dump().map { FavouriteBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
|
||||
BackupSection.SETTINGS -> output.writeString(
|
||||
section = BackupSection.SETTINGS,
|
||||
data = dumpSettings(),
|
||||
)
|
||||
BackupSection.SETTINGS -> output.writeString(
|
||||
section = BackupSection.SETTINGS,
|
||||
data = dumpSettings(),
|
||||
)
|
||||
|
||||
BackupSection.SETTINGS_READER_GRID -> output.writeString(
|
||||
section = BackupSection.SETTINGS_READER_GRID,
|
||||
data = dumpReaderGridSettings(),
|
||||
)
|
||||
BackupSection.SETTINGS_READER_GRID -> output.writeString(
|
||||
section = BackupSection.SETTINGS_READER_GRID,
|
||||
data = dumpReaderGridSettings(),
|
||||
)
|
||||
|
||||
BackupSection.BOOKMARKS -> output.writeJsonArray(
|
||||
section = BackupSection.BOOKMARKS,
|
||||
data = database.getBookmarksDao().dump().map { BookmarkBackup(it.first, it.second) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
BackupSection.BOOKMARKS -> output.writeJsonArray(
|
||||
section = BackupSection.BOOKMARKS,
|
||||
data = database.getBookmarksDao().dump().map { BookmarkBackup(it.first, it.second) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
|
||||
BackupSection.SOURCES -> output.writeJsonArray(
|
||||
section = BackupSection.SOURCES,
|
||||
data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
}
|
||||
progress?.emit(commonProgress)
|
||||
commonProgress++
|
||||
}
|
||||
progress?.emit(commonProgress)
|
||||
}
|
||||
BackupSection.SOURCES -> output.writeJsonArray(
|
||||
section = BackupSection.SOURCES,
|
||||
data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
|
||||
suspend fun restoreBackup(
|
||||
input: ZipInputStream,
|
||||
sections: Set<BackupSection>,
|
||||
progress: FlowCollector<Progress>?,
|
||||
): CompositeResult {
|
||||
progress?.emit(Progress.INDETERMINATE)
|
||||
var commonProgress = Progress(0, sections.size)
|
||||
var entry = input.nextEntry
|
||||
var result = CompositeResult.EMPTY
|
||||
while (entry != null) {
|
||||
val section = BackupSection.of(entry)
|
||||
if (section in sections) {
|
||||
result = result + when (section) {
|
||||
BackupSection.INDEX -> CompositeResult.EMPTY // useless in our case
|
||||
BackupSection.HISTORY -> input.readJsonArray<HistoryBackup>(serializer()).restoreToDb {
|
||||
upsertManga(it.manga)
|
||||
getHistoryDao().upsert(it.toEntity())
|
||||
}
|
||||
BackupSection.SCROBBLING -> output.writeJsonArray(
|
||||
section = BackupSection.SCROBBLING,
|
||||
data = database.getScrobblingDao().dumpEnabled().map { ScrobblingBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
|
||||
BackupSection.CATEGORIES -> input.readJsonArray<CategoryBackup>(serializer()).restoreToDb {
|
||||
getFavouriteCategoriesDao().upsert(it.toEntity())
|
||||
}
|
||||
BackupSection.STATS -> output.writeJsonArray(
|
||||
section = BackupSection.STATS,
|
||||
data = database.getStatsDao().dumpEnabled().map { StatisticBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
|
||||
BackupSection.FAVOURITES -> input.readJsonArray<FavouriteBackup>(serializer()).restoreToDb {
|
||||
upsertManga(it.manga)
|
||||
getFavouritesDao().upsert(it.toEntity())
|
||||
}
|
||||
BackupSection.SAVED_FILTERS -> {
|
||||
val sources = mangaSourcesRepository.getEnabledSources()
|
||||
val filters = sources.flatMap { source ->
|
||||
savedFiltersRepository.getAll(source)
|
||||
}
|
||||
output.writeJsonArray(
|
||||
section = BackupSection.SAVED_FILTERS,
|
||||
data = filters.asFlow(),
|
||||
serializer = serializer(),
|
||||
)
|
||||
}
|
||||
}
|
||||
progress?.emit(commonProgress)
|
||||
commonProgress++
|
||||
}
|
||||
progress?.emit(commonProgress)
|
||||
}
|
||||
|
||||
BackupSection.SETTINGS -> input.readMap().let {
|
||||
settings.upsertAll(it)
|
||||
CompositeResult.success()
|
||||
}
|
||||
suspend fun restoreBackup(
|
||||
input: ZipInputStream,
|
||||
sections: Set<BackupSection>,
|
||||
progress: FlowCollector<Progress>?,
|
||||
): CompositeResult {
|
||||
progress?.emit(Progress.INDETERMINATE)
|
||||
var commonProgress = Progress(0, sections.size)
|
||||
var entry = input.nextEntry
|
||||
var result = CompositeResult.EMPTY
|
||||
while (entry != null) {
|
||||
val section = BackupSection.of(entry)
|
||||
if (section in sections) {
|
||||
result += when (section) {
|
||||
BackupSection.INDEX -> CompositeResult.EMPTY // useless in our case
|
||||
BackupSection.HISTORY -> input.readJsonArray<HistoryBackup>(serializer()).restoreToDb {
|
||||
upsertManga(it.manga)
|
||||
getHistoryDao().upsert(it.toEntity())
|
||||
}
|
||||
|
||||
BackupSection.SETTINGS_READER_GRID -> input.readMap().let {
|
||||
tapGridSettings.upsertAll(it)
|
||||
CompositeResult.success()
|
||||
}
|
||||
BackupSection.CATEGORIES -> input.readJsonArray<CategoryBackup>(serializer()).restoreToDb {
|
||||
getFavouriteCategoriesDao().upsert(it.toEntity())
|
||||
}
|
||||
|
||||
BackupSection.BOOKMARKS -> input.readJsonArray<BookmarkBackup>(serializer()).restoreToDb {
|
||||
upsertManga(it.manga)
|
||||
getBookmarksDao().upsert(it.bookmarks.map { b -> b.toEntity() })
|
||||
}
|
||||
BackupSection.FAVOURITES -> input.readJsonArray<FavouriteBackup>(serializer()).restoreToDb {
|
||||
upsertManga(it.manga)
|
||||
getFavouritesDao().upsert(it.toEntity())
|
||||
}
|
||||
|
||||
BackupSection.SOURCES -> input.readJsonArray<SourceBackup>(serializer()).restoreToDb {
|
||||
getSourcesDao().upsert(it.toEntity())
|
||||
}
|
||||
BackupSection.SETTINGS -> input.readMap().let {
|
||||
settings.upsertAll(it)
|
||||
CompositeResult.success()
|
||||
}
|
||||
|
||||
null -> CompositeResult.EMPTY // skip unknown entries
|
||||
}
|
||||
progress?.emit(commonProgress)
|
||||
commonProgress++
|
||||
}
|
||||
input.closeEntry()
|
||||
entry = input.nextEntry
|
||||
}
|
||||
progress?.emit(commonProgress)
|
||||
return result
|
||||
}
|
||||
BackupSection.SETTINGS_READER_GRID -> input.readMap().let {
|
||||
tapGridSettings.upsertAll(it)
|
||||
CompositeResult.success()
|
||||
}
|
||||
|
||||
private suspend fun <T> ZipOutputStream.writeJsonArray(
|
||||
section: BackupSection,
|
||||
data: Flow<T>,
|
||||
serializer: SerializationStrategy<T>,
|
||||
) {
|
||||
data.onStart {
|
||||
putNextEntry(ZipEntry(section.entryName))
|
||||
write("[")
|
||||
}.onCompletion { error ->
|
||||
if (error == null) {
|
||||
write("]")
|
||||
}
|
||||
closeEntry()
|
||||
flush()
|
||||
}.collectIndexed { index, value ->
|
||||
if (index > 0) {
|
||||
write(",")
|
||||
}
|
||||
json.encodeToStream(serializer, value, this)
|
||||
}
|
||||
}
|
||||
BackupSection.BOOKMARKS -> input.readJsonArray<BookmarkBackup>(serializer()).restoreToDb {
|
||||
upsertManga(it.manga)
|
||||
getBookmarksDao().upsert(it.bookmarks.map { b -> b.toEntity() })
|
||||
}
|
||||
|
||||
private fun <T> InputStream.readJsonArray(
|
||||
serializer: DeserializationStrategy<T>,
|
||||
): Sequence<T> = json.decodeToSequence(this, serializer, DecodeSequenceMode.ARRAY_WRAPPED)
|
||||
BackupSection.SOURCES -> input.readJsonArray<SourceBackup>(serializer()).restoreToDb {
|
||||
getSourcesDao().upsert(it.toEntity())
|
||||
}
|
||||
|
||||
private fun InputStream.readMap(): Map<String, Any?> {
|
||||
val jo = JSONArray(readString()).getJSONObject(0)
|
||||
val map = ArrayMap<String, Any?>(jo.length())
|
||||
val keys = jo.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
map[key] = jo.get(key)
|
||||
}
|
||||
return map
|
||||
}
|
||||
BackupSection.SCROBBLING -> input.readJsonArray<ScrobblingBackup>(serializer()).restoreToDb {
|
||||
getScrobblingDao().upsert(it.toEntity())
|
||||
}
|
||||
|
||||
private fun ZipOutputStream.writeString(
|
||||
section: BackupSection,
|
||||
data: String,
|
||||
) {
|
||||
putNextEntry(ZipEntry(section.entryName))
|
||||
try {
|
||||
write("[")
|
||||
write(data)
|
||||
write("]")
|
||||
} finally {
|
||||
closeEntry()
|
||||
flush()
|
||||
}
|
||||
}
|
||||
BackupSection.STATS -> input.readJsonArray<StatisticBackup>(serializer()).restoreToDb {
|
||||
getStatsDao().upsert(it.toEntity())
|
||||
}
|
||||
|
||||
private fun OutputStream.write(str: String) = write(str.toByteArray())
|
||||
BackupSection.SAVED_FILTERS -> input.readJsonArray<PersistableFilter>(serializer())
|
||||
.restoreWithoutTransaction {
|
||||
savedFiltersRepository.save(it)
|
||||
}
|
||||
|
||||
private fun InputStream.readString(): String = readBytes().decodeToString()
|
||||
null -> CompositeResult.EMPTY // skip unknown entries
|
||||
}
|
||||
progress?.emit(commonProgress)
|
||||
commonProgress++
|
||||
}
|
||||
input.closeEntry()
|
||||
entry = input.nextEntry
|
||||
}
|
||||
progress?.emit(commonProgress)
|
||||
return result
|
||||
}
|
||||
|
||||
private fun dumpSettings(): String {
|
||||
val map = settings.getAllValues().toMutableMap()
|
||||
map.remove(AppSettings.KEY_APP_PASSWORD)
|
||||
map.remove(AppSettings.KEY_PROXY_PASSWORD)
|
||||
map.remove(AppSettings.KEY_PROXY_LOGIN)
|
||||
map.remove(AppSettings.KEY_INCOGNITO_MODE)
|
||||
return JSONObject(map).toString()
|
||||
}
|
||||
private suspend fun <T> ZipOutputStream.writeJsonArray(
|
||||
section: BackupSection,
|
||||
data: Flow<T>,
|
||||
serializer: SerializationStrategy<T>,
|
||||
) {
|
||||
data.onStart {
|
||||
putNextEntry(ZipEntry(section.entryName))
|
||||
write("[")
|
||||
}.onCompletion { error ->
|
||||
if (error == null) {
|
||||
write("]")
|
||||
}
|
||||
closeEntry()
|
||||
flush()
|
||||
}.collectIndexed { index, value ->
|
||||
if (index > 0) {
|
||||
write(",")
|
||||
}
|
||||
json.encodeToStream(serializer, value, this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dumpReaderGridSettings(): String {
|
||||
return JSONObject(tapGridSettings.getAllValues()).toString()
|
||||
}
|
||||
private fun <T> InputStream.readJsonArray(
|
||||
serializer: DeserializationStrategy<T>,
|
||||
): Sequence<T> = json.decodeToSequence(this, serializer, DecodeSequenceMode.ARRAY_WRAPPED)
|
||||
|
||||
private suspend fun MangaDatabase.upsertManga(manga: MangaBackup) {
|
||||
val tags = manga.tags.map { it.toEntity() }
|
||||
getTagsDao().upsert(tags)
|
||||
getMangaDao().upsert(manga.toEntity(), tags)
|
||||
}
|
||||
private fun InputStream.readMap(): Map<String, Any?> {
|
||||
val jo = JSONArray(readString()).getJSONObject(0)
|
||||
val map = ArrayMap<String, Any?>(jo.length())
|
||||
val keys = jo.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
map[key] = jo.get(key)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
private suspend inline fun <T> Sequence<T>.restoreToDb(crossinline block: suspend MangaDatabase.(T) -> Unit): CompositeResult {
|
||||
return fold(CompositeResult.EMPTY) { result, item ->
|
||||
result + runCatchingCancellable {
|
||||
database.withTransaction {
|
||||
database.block(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun ZipOutputStream.writeString(
|
||||
section: BackupSection,
|
||||
data: String,
|
||||
) {
|
||||
putNextEntry(ZipEntry(section.entryName))
|
||||
try {
|
||||
write("[")
|
||||
write(data)
|
||||
write("]")
|
||||
} finally {
|
||||
closeEntry()
|
||||
flush()
|
||||
}
|
||||
}
|
||||
|
||||
private fun OutputStream.write(str: String) = write(str.toByteArray())
|
||||
|
||||
private fun InputStream.readString(): String = readBytes().decodeToString()
|
||||
|
||||
private fun dumpSettings(): String {
|
||||
val map = settings.getAllValues().toMutableMap()
|
||||
map.remove(AppSettings.KEY_APP_PASSWORD)
|
||||
map.remove(AppSettings.KEY_PROXY_PASSWORD)
|
||||
map.remove(AppSettings.KEY_PROXY_LOGIN)
|
||||
map.remove(AppSettings.KEY_INCOGNITO_MODE)
|
||||
return JSONObject(map).toString()
|
||||
}
|
||||
|
||||
private fun dumpReaderGridSettings(): String {
|
||||
return JSONObject(tapGridSettings.getAllValues()).toString()
|
||||
}
|
||||
|
||||
private suspend fun MangaDatabase.upsertManga(manga: MangaBackup) {
|
||||
val tags = manga.tags.map { it.toEntity() }
|
||||
getTagsDao().upsert(tags)
|
||||
getMangaDao().upsert(manga.toEntity(), tags)
|
||||
}
|
||||
|
||||
private suspend inline fun <T> Sequence<T>.restoreToDb(crossinline block: suspend MangaDatabase.(T) -> Unit): CompositeResult {
|
||||
return fold(CompositeResult.EMPTY) { result, item ->
|
||||
result + runCatchingCancellable {
|
||||
database.withTransaction {
|
||||
database.block(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun <T> Sequence<T>.restoreWithoutTransaction(crossinline block: suspend (T) -> Unit): CompositeResult {
|
||||
return fold(CompositeResult.EMPTY) { result, item ->
|
||||
result + runCatchingCancellable {
|
||||
block(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.koitharu.kotatsu.backups.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
||||
|
||||
@Serializable
|
||||
class ScrobblingBackup(
|
||||
@SerialName("scrobbler") val scrobbler: Int,
|
||||
@SerialName("id") val id: Int,
|
||||
@SerialName("manga_id") val mangaId: Long,
|
||||
@SerialName("target_id") val targetId: Long,
|
||||
@SerialName("status") val status: String?,
|
||||
@SerialName("chapter") val chapter: Int,
|
||||
@SerialName("comment") val comment: String?,
|
||||
@SerialName("rating") val rating: Float,
|
||||
) {
|
||||
|
||||
constructor(entity: ScrobblingEntity) : this(
|
||||
scrobbler = entity.scrobbler,
|
||||
id = entity.id,
|
||||
mangaId = entity.mangaId,
|
||||
targetId = entity.targetId,
|
||||
status = entity.status,
|
||||
chapter = entity.chapter,
|
||||
comment = entity.comment,
|
||||
rating = entity.rating,
|
||||
)
|
||||
|
||||
fun toEntity() = ScrobblingEntity(
|
||||
scrobbler = scrobbler,
|
||||
id = id,
|
||||
mangaId = mangaId,
|
||||
targetId = targetId,
|
||||
status = status,
|
||||
chapter = chapter,
|
||||
comment = comment,
|
||||
rating = rating,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.koitharu.kotatsu.backups.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.koitharu.kotatsu.stats.data.StatsEntity
|
||||
|
||||
@Serializable
|
||||
class StatisticBackup(
|
||||
@SerialName("manga_id") val mangaId: Long,
|
||||
@SerialName("started_at") val startedAt: Long,
|
||||
@SerialName("duration") val duration: Long,
|
||||
@SerialName("pages") val pages: Int,
|
||||
) {
|
||||
|
||||
constructor(entity: StatsEntity) : this(
|
||||
mangaId = entity.mangaId,
|
||||
startedAt = entity.startedAt,
|
||||
duration = entity.duration,
|
||||
pages = entity.pages,
|
||||
)
|
||||
|
||||
fun toEntity() = StatsEntity(
|
||||
mangaId = mangaId,
|
||||
startedAt = startedAt,
|
||||
duration = duration,
|
||||
pages = pages,
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import kotlinx.coroutines.runBlocking
|
||||
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
|
||||
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
||||
import java.io.File
|
||||
import java.io.FileDescriptor
|
||||
@@ -36,15 +38,22 @@ class AppBackupAgent : BackupAgent() {
|
||||
|
||||
override fun onFullBackup(data: FullBackupDataOutput) {
|
||||
super.onFullBackup(data)
|
||||
val file =
|
||||
createBackupFile(
|
||||
this,
|
||||
BackupRepository(
|
||||
MangaDatabase(context = applicationContext),
|
||||
AppSettings(applicationContext),
|
||||
TapGridSettings(applicationContext),
|
||||
val file = createBackupFile(
|
||||
this,
|
||||
BackupRepository(
|
||||
database = MangaDatabase(context = applicationContext),
|
||||
settings = AppSettings(applicationContext),
|
||||
tapGridSettings = TapGridSettings(applicationContext),
|
||||
mangaSourcesRepository = MangaSourcesRepository(
|
||||
context = applicationContext,
|
||||
db = MangaDatabase(context = applicationContext),
|
||||
settings = AppSettings(applicationContext),
|
||||
),
|
||||
)
|
||||
savedFiltersRepository = SavedFiltersRepository(
|
||||
context = applicationContext,
|
||||
),
|
||||
),
|
||||
)
|
||||
try {
|
||||
fullBackupFile(file, data)
|
||||
} finally {
|
||||
@@ -68,6 +77,14 @@ class AppBackupAgent : BackupAgent() {
|
||||
database = MangaDatabase(applicationContext),
|
||||
settings = AppSettings(applicationContext),
|
||||
tapGridSettings = TapGridSettings(applicationContext),
|
||||
mangaSourcesRepository = MangaSourcesRepository(
|
||||
context = applicationContext,
|
||||
db = MangaDatabase(context = applicationContext),
|
||||
settings = AppSettings(applicationContext),
|
||||
),
|
||||
savedFiltersRepository = SavedFiltersRepository(
|
||||
context = applicationContext,
|
||||
),
|
||||
),
|
||||
)
|
||||
destination.delete()
|
||||
@@ -90,8 +107,12 @@ class AppBackupAgent : BackupAgent() {
|
||||
@VisibleForTesting
|
||||
fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) {
|
||||
ZipInputStream(ByteStreams.limit(FileInputStream(fd), size)).use { input ->
|
||||
val sections = EnumSet.allOf(BackupSection::class.java)
|
||||
// managed externally
|
||||
sections.remove(BackupSection.SETTINGS)
|
||||
sections.remove(BackupSection.SETTINGS_READER_GRID)
|
||||
runBlocking {
|
||||
repository.restoreBackup(input, EnumSet.allOf(BackupSection::class.java), null)
|
||||
repository.restoreBackup(input, sections, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,16 @@ enum class BackupSection(
|
||||
SETTINGS_READER_GRID("reader_grid"),
|
||||
BOOKMARKS("bookmarks"),
|
||||
SOURCES("sources"),
|
||||
SCROBBLING("scrobbling"),
|
||||
STATS("statistics"),
|
||||
SAVED_FILTERS("saved_filters"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
||||
fun of(entry: ZipEntry): BackupSection? {
|
||||
val name = entry.name.lowercase(Locale.ROOT)
|
||||
return entries.first { x -> x.entryName == name }
|
||||
return entries.find { x -> x.entryName == name }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class TelegramBackupUploader @Inject constructor(
|
||||
suspend fun uploadBackup(file: File) {
|
||||
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
|
||||
val multipartBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.Companion.FORM)
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("chat_id", requireChatId())
|
||||
.addFormDataPart("document", file.name, requestBody)
|
||||
.build()
|
||||
|
||||
@@ -23,6 +23,9 @@ data class BackupSectionModel(
|
||||
BackupSection.SETTINGS_READER_GRID -> R.string.reader_actions
|
||||
BackupSection.BOOKMARKS -> R.string.bookmarks
|
||||
BackupSection.SOURCES -> R.string.remote_sources
|
||||
BackupSection.SCROBBLING -> R.string.tracking
|
||||
BackupSection.STATS -> R.string.statistics
|
||||
BackupSection.SAVED_FILTERS -> R.string.saved_filters
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
|
||||
@@ -8,7 +8,6 @@ import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.room.InvalidationTracker
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -28,7 +27,6 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.os.AppValidator
|
||||
import org.koitharu.kotatsu.core.os.RomCompat
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
@@ -63,9 +61,6 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
@Inject
|
||||
lateinit var workScheduleManager: WorkScheduleManager
|
||||
|
||||
@Inject
|
||||
lateinit var workManagerProvider: Provider<WorkManager>
|
||||
|
||||
@Inject
|
||||
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>
|
||||
|
||||
@@ -99,7 +94,6 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
localStorageChanges.collect(localMangaIndexProvider.get())
|
||||
}
|
||||
workScheduleManager.init()
|
||||
WorkServiceStopHelper(workManagerProvider).setup()
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
|
||||
@@ -34,6 +34,9 @@ abstract class MangaDao {
|
||||
@Query("SELECT author FROM manga WHERE author LIKE :query GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit")
|
||||
abstract suspend fun findAuthors(query: String, limit: Int): List<String>
|
||||
|
||||
@Query("SELECT author FROM manga WHERE manga.source = :source AND author IS NOT NULL AND author != '' GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit")
|
||||
abstract suspend fun findAuthorsBySource(source: String, limit: Int): List<String>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
|
||||
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
class EmptyMangaException(
|
||||
val reason: EmptyMangaReason?,
|
||||
val manga: Manga,
|
||||
cause: Throwable?
|
||||
) : IllegalStateException(cause)
|
||||
@@ -8,13 +8,15 @@ import androidx.collection.MutableScatterMap
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.async
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
|
||||
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
||||
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
@@ -24,6 +26,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.restartApplication
|
||||
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
|
||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
@@ -32,164 +35,221 @@ import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredExcept
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
|
||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||
import java.security.cert.CertPathValidatorException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import javax.net.ssl.SSLException
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class ExceptionResolver @AssistedInject constructor(
|
||||
@Assisted private val host: Host,
|
||||
private val settings: AppSettings,
|
||||
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
||||
class ExceptionResolver private constructor(
|
||||
private val host: Host,
|
||||
private val settings: AppSettings,
|
||||
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
||||
) {
|
||||
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
|
||||
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
|
||||
|
||||
private val browserActionContract = host.registerForActivityResult(BrowserActivity.Contract()) {
|
||||
handleActivityResult(BrowserActivity.TAG, true)
|
||||
}
|
||||
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
|
||||
handleActivityResult(SourceAuthActivity.TAG, it)
|
||||
}
|
||||
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
|
||||
handleActivityResult(CloudFlareActivity.TAG, it)
|
||||
}
|
||||
private val browserActionContract = host.registerForActivityResult(BrowserActivity.Contract()) {
|
||||
handleActivityResult(BrowserActivity.TAG, true)
|
||||
}
|
||||
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
|
||||
handleActivityResult(SourceAuthActivity.TAG, it)
|
||||
}
|
||||
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
|
||||
handleActivityResult(CloudFlareActivity.TAG, it)
|
||||
}
|
||||
|
||||
fun showErrorDetails(e: Throwable, url: String? = null) {
|
||||
host.router()?.showErrorDialog(e, url)
|
||||
}
|
||||
fun showErrorDetails(e: Throwable, url: String? = null) {
|
||||
host.router.showErrorDialog(e, url)
|
||||
}
|
||||
|
||||
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
||||
is CloudFlareProtectedException -> resolveCF(e)
|
||||
is AuthRequiredException -> resolveAuthException(e.source)
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> {
|
||||
showSslErrorDialog()
|
||||
false
|
||||
}
|
||||
suspend fun resolve(e: Throwable): Boolean = host.lifecycleScope.async {
|
||||
when (e) {
|
||||
is CloudFlareProtectedException -> resolveCF(e)
|
||||
is AuthRequiredException -> resolveAuthException(e.source)
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> {
|
||||
showSslErrorDialog()
|
||||
false
|
||||
}
|
||||
|
||||
is InteractiveActionRequiredException -> resolveBrowserAction(e)
|
||||
is InteractiveActionRequiredException -> resolveBrowserAction(e)
|
||||
|
||||
is ProxyConfigException -> {
|
||||
host.router()?.openProxySettings()
|
||||
false
|
||||
}
|
||||
is ProxyConfigException -> {
|
||||
host.router.openProxySettings()
|
||||
false
|
||||
}
|
||||
|
||||
is NotFoundException -> {
|
||||
openInBrowser(e.url)
|
||||
false
|
||||
}
|
||||
is NotFoundException -> {
|
||||
openInBrowser(e.url)
|
||||
false
|
||||
}
|
||||
|
||||
is UnsupportedSourceException -> {
|
||||
e.manga?.let { openAlternatives(it) }
|
||||
false
|
||||
}
|
||||
is EmptyMangaException -> {
|
||||
when (e.reason) {
|
||||
EmptyMangaReason.NO_CHAPTERS -> openAlternatives(e.manga)
|
||||
EmptyMangaReason.LOADING_ERROR -> Unit
|
||||
EmptyMangaReason.RESTRICTED -> host.router.openBrowser(e.manga)
|
||||
else -> Unit
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
is ScrobblerAuthRequiredException -> {
|
||||
val authHelper = scrobblerAuthHelperProvider.get()
|
||||
if (authHelper.isAuthorized(e.scrobbler)) {
|
||||
true
|
||||
} else {
|
||||
host.withContext {
|
||||
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
is UnsupportedSourceException -> {
|
||||
e.manga?.let { openAlternatives(it) }
|
||||
false
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
is ScrobblerAuthRequiredException -> {
|
||||
val authHelper = scrobblerAuthHelperProvider.get()
|
||||
if (authHelper.isAuthorized(e.scrobbler)) {
|
||||
true
|
||||
} else {
|
||||
host.withContext {
|
||||
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveBrowserAction(
|
||||
e: InteractiveActionRequiredException
|
||||
): Boolean = suspendCoroutine { cont ->
|
||||
continuations[BrowserActivity.TAG] = cont
|
||||
browserActionContract.launch(e)
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}.await()
|
||||
|
||||
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
|
||||
continuations[CloudFlareActivity.TAG] = cont
|
||||
cloudflareContract.launch(e)
|
||||
}
|
||||
private suspend fun resolveBrowserAction(
|
||||
e: InteractiveActionRequiredException
|
||||
): Boolean = suspendCoroutine { cont ->
|
||||
continuations[BrowserActivity.TAG] = cont
|
||||
browserActionContract.launch(e)
|
||||
}
|
||||
|
||||
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
|
||||
continuations[SourceAuthActivity.TAG] = cont
|
||||
sourceAuthContract.launch(source)
|
||||
}
|
||||
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
|
||||
continuations[CloudFlareActivity.TAG] = cont
|
||||
cloudflareContract.launch(e)
|
||||
}
|
||||
|
||||
private fun openInBrowser(url: String) {
|
||||
host.router()?.openBrowser(url, null, null)
|
||||
}
|
||||
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
|
||||
continuations[SourceAuthActivity.TAG] = cont
|
||||
sourceAuthContract.launch(source)
|
||||
}
|
||||
|
||||
private fun openAlternatives(manga: Manga) {
|
||||
host.router()?.openAlternatives(manga)
|
||||
}
|
||||
private fun openInBrowser(url: String) {
|
||||
host.router.openBrowser(url, null, null)
|
||||
}
|
||||
|
||||
private fun handleActivityResult(tag: String, result: Boolean) {
|
||||
continuations.remove(tag)?.resume(result)
|
||||
}
|
||||
private fun openAlternatives(manga: Manga) {
|
||||
host.router.openAlternatives(manga)
|
||||
}
|
||||
|
||||
private fun showSslErrorDialog() {
|
||||
val ctx = host.getContext() ?: return
|
||||
if (settings.isSSLBypassEnabled) {
|
||||
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
buildAlertDialog(ctx) {
|
||||
setTitle(R.string.ignore_ssl_errors)
|
||||
setMessage(R.string.ignore_ssl_errors_summary)
|
||||
setPositiveButton(R.string.apply) { _, _ ->
|
||||
settings.isSSLBypassEnabled = true
|
||||
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
|
||||
ctx.restartApplication()
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
}.show()
|
||||
}
|
||||
private fun handleActivityResult(tag: String, result: Boolean) {
|
||||
continuations.remove(tag)?.resume(result)
|
||||
}
|
||||
|
||||
private inline fun Host.withContext(block: Context.() -> Unit) {
|
||||
getContext()?.apply(block)
|
||||
}
|
||||
private fun showSslErrorDialog() {
|
||||
val ctx = host.context ?: return
|
||||
if (settings.isSSLBypassEnabled) {
|
||||
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
buildAlertDialog(ctx) {
|
||||
setTitle(R.string.ignore_ssl_errors)
|
||||
setMessage(R.string.ignore_ssl_errors_summary)
|
||||
setPositiveButton(R.string.apply) { _, _ ->
|
||||
settings.isSSLBypassEnabled = true
|
||||
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
|
||||
ctx.restartApplication()
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun Host.router(): AppRouter? = when (this) {
|
||||
is FragmentActivity -> router
|
||||
is Fragment -> router
|
||||
else -> null
|
||||
}
|
||||
class Factory @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
||||
) {
|
||||
|
||||
interface Host : ActivityResultCaller {
|
||||
fun create(fragment: Fragment) = ExceptionResolver(
|
||||
host = Host.FragmentHost(fragment),
|
||||
settings = settings,
|
||||
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
|
||||
)
|
||||
|
||||
fun getChildFragmentManager(): FragmentManager
|
||||
fun create(activity: FragmentActivity) = ExceptionResolver(
|
||||
host = Host.ActivityHost(activity),
|
||||
settings = settings,
|
||||
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
|
||||
)
|
||||
}
|
||||
|
||||
fun getContext(): Context?
|
||||
}
|
||||
private sealed interface Host : ActivityResultCaller, LifecycleOwner {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
val context: Context?
|
||||
|
||||
fun create(host: Host): ExceptionResolver
|
||||
}
|
||||
val router: AppRouter
|
||||
|
||||
companion object {
|
||||
val fragmentManager: FragmentManager
|
||||
|
||||
@StringRes
|
||||
fun getResolveStringId(e: Throwable) = when (e) {
|
||||
is CloudFlareProtectedException -> R.string.captcha_solve
|
||||
is ScrobblerAuthRequiredException,
|
||||
is AuthRequiredException -> R.string.sign_in
|
||||
inline fun withContext(block: Context.() -> Unit) {
|
||||
context?.apply(block)
|
||||
}
|
||||
|
||||
is NotFoundException -> if (e.url.isHttpUrl()) R.string.open_in_browser else 0
|
||||
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> R.string.fix
|
||||
class ActivityHost(val activity: FragmentActivity) : Host,
|
||||
ActivityResultCaller by activity,
|
||||
LifecycleOwner by activity {
|
||||
|
||||
is ProxyConfigException -> R.string.settings
|
||||
override val context: Context
|
||||
get() = activity
|
||||
|
||||
is InteractiveActionRequiredException -> R.string._continue
|
||||
override val router: AppRouter
|
||||
get() = activity.router
|
||||
|
||||
else -> 0
|
||||
}
|
||||
override val fragmentManager: FragmentManager
|
||||
get() = activity.supportFragmentManager
|
||||
}
|
||||
|
||||
fun canResolve(e: Throwable) = getResolveStringId(e) != 0
|
||||
}
|
||||
class FragmentHost(val fragment: Fragment) : Host,
|
||||
ActivityResultCaller by fragment {
|
||||
|
||||
override val context: Context?
|
||||
get() = fragment.context
|
||||
|
||||
override val router: AppRouter
|
||||
get() = fragment.router
|
||||
|
||||
override val fragmentManager: FragmentManager
|
||||
get() = fragment.childFragmentManager
|
||||
|
||||
override val lifecycle: Lifecycle
|
||||
get() = fragment.viewLifecycleOwner.lifecycle
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@StringRes
|
||||
fun getResolveStringId(e: Throwable) = when (e) {
|
||||
is CloudFlareProtectedException -> R.string.captcha_solve
|
||||
is ScrobblerAuthRequiredException,
|
||||
is AuthRequiredException -> R.string.sign_in
|
||||
|
||||
is NotFoundException -> if (e.url.isHttpUrl()) R.string.open_in_browser else 0
|
||||
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> R.string.fix
|
||||
|
||||
is ProxyConfigException -> R.string.settings
|
||||
|
||||
is InteractiveActionRequiredException -> R.string._continue
|
||||
|
||||
is EmptyMangaException -> when (e.reason) {
|
||||
EmptyMangaReason.RESTRICTED -> if (e.manga.publicUrl.isHttpUrl()) R.string.open_in_browser else 0
|
||||
EmptyMangaReason.NO_CHAPTERS -> R.string.alternatives
|
||||
else -> 0
|
||||
}
|
||||
|
||||
else -> 0
|
||||
}
|
||||
|
||||
fun canResolve(e: Throwable) = getResolveStringId(e) != 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.descriptors.serialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
object MangaSourceSerializer : KSerializer<MangaSource> {
|
||||
|
||||
override val descriptor: SerialDescriptor = serialDescriptor<String>()
|
||||
|
||||
override fun serialize(
|
||||
encoder: Encoder,
|
||||
value: MangaSource
|
||||
) = encoder.encodeString(value.name)
|
||||
|
||||
override fun deserialize(decoder: Decoder): MangaSource = MangaSource(decoder.decodeString())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.network.webview
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AndroidRuntimeException
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
@@ -41,7 +42,13 @@ class WebViewExecutor @Inject constructor(
|
||||
private val mutex = Mutex()
|
||||
|
||||
val defaultUserAgent: String? by lazy {
|
||||
WebSettings.getDefaultUserAgent(context)
|
||||
try {
|
||||
WebSettings.getDefaultUserAgent(context)
|
||||
} catch (e: AndroidRuntimeException) {
|
||||
e.printStackTraceDebug()
|
||||
// Probably WebView is not available
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun evaluateJs(baseUrl: String?, script: String): String? = mutex.withLock {
|
||||
|
||||
@@ -19,6 +19,7 @@ import coil3.request.Options
|
||||
import coil3.size.pxOrElse
|
||||
import coil3.toAndroidUri
|
||||
import coil3.toBitmap
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.FileSystem
|
||||
@@ -41,7 +42,6 @@ import org.koitharu.kotatsu.local.data.LocalStorageCache
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import coil3.Uri as CoilUri
|
||||
|
||||
class FaviconFetcher(
|
||||
@@ -88,7 +88,7 @@ class FaviconFetcher(
|
||||
var favicons = repository.getFavicons()
|
||||
var lastError: Exception? = null
|
||||
while (favicons.isNotEmpty()) {
|
||||
coroutineContext.ensureActive()
|
||||
currentCoroutineContext().ensureActive()
|
||||
val icon = favicons.find(sizePx) ?: throwNSEE(lastError)
|
||||
try {
|
||||
val result = imageLoader.fetch(icon.url, options)
|
||||
|
||||
@@ -138,6 +138,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
|
||||
|
||||
var isReaderDoubleOnFoldable: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_DOUBLE_FOLDABLE, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_FOLDABLE, value) }
|
||||
|
||||
@get:FloatRange(0.0, 1.0)
|
||||
var readerDoublePagesSensitivity: Float
|
||||
get() = prefs.getFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, 0.5f)
|
||||
set(@FloatRange(0.0, 1.0) value) = prefs.edit { putFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, value) }
|
||||
|
||||
val readerScreenOrientation: Int
|
||||
get() = prefs.getString(KEY_READER_ORIENTATION, null)?.toIntOrNull()
|
||||
?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
@@ -404,6 +413,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isReaderBarTransparent: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_BAR_TRANSPARENT, true)
|
||||
|
||||
val isReaderChapterToastEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_CHAPTER_TOAST, true)
|
||||
|
||||
val isReaderKeepScreenOn: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
|
||||
|
||||
@@ -538,11 +550,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isPeriodicalBackupEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false)
|
||||
|
||||
val periodicalBackupFrequency: Long
|
||||
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L
|
||||
val periodicalBackupFrequency: Float
|
||||
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toFloatOrNull() ?: 7f
|
||||
|
||||
val periodicalBackupFrequencyMillis: Long
|
||||
get() = TimeUnit.DAYS.toMillis(periodicalBackupFrequency)
|
||||
get() = (TimeUnit.DAYS.toMillis(1) * periodicalBackupFrequency).toLong()
|
||||
|
||||
val periodicalBackupMaxCount: Int
|
||||
get() = if (prefs.getBoolean(KEY_BACKUP_PERIODICAL_TRIM, true)) {
|
||||
@@ -673,6 +685,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_REMOTE_SOURCES = "remote_sources"
|
||||
const val KEY_LOCAL_STORAGE = "local_storage"
|
||||
const val KEY_READER_DOUBLE_PAGES = "reader_double_pages"
|
||||
const val KEY_READER_DOUBLE_PAGES_SENSITIVITY = "reader_double_pages_sensitivity_2"
|
||||
const val KEY_READER_DOUBLE_FOLDABLE = "reader_double_foldable"
|
||||
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
|
||||
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
|
||||
const val KEY_READER_NAVIGATION_INVERTED = "reader_navigation_inverted"
|
||||
@@ -741,6 +755,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_SYNC_SETTINGS = "sync_settings"
|
||||
const val KEY_READER_BAR = "reader_bar"
|
||||
const val KEY_READER_BAR_TRANSPARENT = "reader_bar_transparent"
|
||||
const val KEY_READER_CHAPTER_TOAST = "reader_chapter_toast"
|
||||
const val KEY_READER_BACKGROUND = "reader_background"
|
||||
const val KEY_READER_SCREEN_ON = "reader_screen_on"
|
||||
const val KEY_SHORTCUTS = "dynamic_shortcuts"
|
||||
|
||||
@@ -13,10 +13,14 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.settings.utils.validation.DomainValidator
|
||||
import java.io.File
|
||||
|
||||
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
|
||||
|
||||
private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE)
|
||||
private val prefs = context.getSharedPreferences(
|
||||
source.name.replace(File.separatorChar, '$'),
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
|
||||
var defaultSortOrder: SortOrder?
|
||||
get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java)
|
||||
|
||||
@@ -33,7 +33,6 @@ import androidx.appcompat.R as appcompatR
|
||||
|
||||
abstract class BaseActivity<B : ViewBinding> :
|
||||
AppCompatActivity(),
|
||||
ExceptionResolver.Host,
|
||||
OnApplyWindowInsetsListener,
|
||||
ScreenshotPolicyHelper.ContentContainer {
|
||||
|
||||
@@ -87,10 +86,6 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
|
||||
override fun setContentView(view: View?) = throw UnsupportedOperationException()
|
||||
|
||||
override fun getContext() = this
|
||||
|
||||
override fun getChildFragmentManager(): FragmentManager = supportFragmentManager
|
||||
|
||||
protected fun setContentView(binding: B) {
|
||||
this.viewBinding = binding
|
||||
super.setContentView(binding.root)
|
||||
|
||||
@@ -15,8 +15,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
|
||||
abstract class BaseFragment<B : ViewBinding> :
|
||||
OnApplyWindowInsetsListener,
|
||||
Fragment(),
|
||||
ExceptionResolver.Host {
|
||||
Fragment() {
|
||||
|
||||
var viewBinding: B? = null
|
||||
private set
|
||||
|
||||
@@ -36,8 +36,7 @@ import com.google.android.material.R as materialR
|
||||
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
PreferenceFragmentCompat(),
|
||||
OnApplyWindowInsetsListener,
|
||||
RecyclerViewOwner,
|
||||
ExceptionResolver.Host {
|
||||
RecyclerViewOwner {
|
||||
|
||||
protected lateinit var exceptionResolver: ExceptionResolver
|
||||
private set
|
||||
|
||||
@@ -2,10 +2,17 @@ package org.koitharu.kotatsu.core.ui.dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.CompoundButton.OnCheckedChangeListener
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.UiContext
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -15,54 +22,103 @@ import com.hannesdorfmann.adapterdelegates4.AdapterDelegatesManager
|
||||
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
|
||||
import org.koitharu.kotatsu.databinding.ViewDialogAutocompleteBinding
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
inline fun buildAlertDialog(
|
||||
@UiContext context: Context,
|
||||
isCentered: Boolean = false,
|
||||
block: MaterialAlertDialogBuilder.() -> Unit,
|
||||
@UiContext context: Context,
|
||||
isCentered: Boolean = false,
|
||||
block: MaterialAlertDialogBuilder.() -> Unit,
|
||||
): AlertDialog = MaterialAlertDialogBuilder(
|
||||
context,
|
||||
if (isCentered) materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered else 0,
|
||||
context,
|
||||
if (isCentered) materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered else 0,
|
||||
).apply(block).create()
|
||||
|
||||
fun <B : AlertDialog.Builder> B.setCheckbox(
|
||||
@StringRes textResId: Int,
|
||||
isChecked: Boolean,
|
||||
onCheckedChangeListener: OnCheckedChangeListener
|
||||
@StringRes textResId: Int,
|
||||
isChecked: Boolean,
|
||||
onCheckedChangeListener: OnCheckedChangeListener
|
||||
) = apply {
|
||||
val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
|
||||
binding.checkbox.setText(textResId)
|
||||
binding.checkbox.isChecked = isChecked
|
||||
binding.checkbox.setOnCheckedChangeListener(onCheckedChangeListener)
|
||||
setView(binding.root)
|
||||
val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
|
||||
binding.checkbox.setText(textResId)
|
||||
binding.checkbox.isChecked = isChecked
|
||||
binding.checkbox.setOnCheckedChangeListener(onCheckedChangeListener)
|
||||
setView(binding.root)
|
||||
}
|
||||
|
||||
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
|
||||
list: List<T>,
|
||||
delegate: AdapterDelegate<List<T>>,
|
||||
list: List<T>,
|
||||
delegate: AdapterDelegate<List<T>>,
|
||||
) = apply {
|
||||
val delegatesManager = AdapterDelegatesManager<List<T>>()
|
||||
delegatesManager.addDelegate(delegate)
|
||||
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
|
||||
val delegatesManager = AdapterDelegatesManager<List<T>>()
|
||||
delegatesManager.addDelegate(delegate)
|
||||
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
|
||||
}
|
||||
|
||||
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
|
||||
list: List<T>,
|
||||
vararg delegates: AdapterDelegate<List<T>>,
|
||||
list: List<T>,
|
||||
vararg delegates: AdapterDelegate<List<T>>,
|
||||
) = apply {
|
||||
val delegatesManager = AdapterDelegatesManager<List<T>>()
|
||||
delegates.forEach { delegatesManager.addDelegate(it) }
|
||||
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
|
||||
val delegatesManager = AdapterDelegatesManager<List<T>>()
|
||||
delegates.forEach { delegatesManager.addDelegate(it) }
|
||||
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
|
||||
}
|
||||
|
||||
fun <B : AlertDialog.Builder> B.setRecyclerViewList(adapter: RecyclerView.Adapter<*>) = apply {
|
||||
val recyclerView = RecyclerView(context)
|
||||
recyclerView.layoutManager = LinearLayoutManager(context)
|
||||
recyclerView.updatePadding(
|
||||
top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing),
|
||||
)
|
||||
recyclerView.clipToPadding = false
|
||||
recyclerView.adapter = adapter
|
||||
setView(recyclerView)
|
||||
val recyclerView = RecyclerView(context)
|
||||
recyclerView.layoutManager = LinearLayoutManager(context)
|
||||
recyclerView.updatePadding(
|
||||
top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing),
|
||||
)
|
||||
recyclerView.clipToPadding = false
|
||||
recyclerView.adapter = adapter
|
||||
setView(recyclerView)
|
||||
}
|
||||
|
||||
fun <B : AlertDialog.Builder> B.setEditText(
|
||||
inputType: Int,
|
||||
singleLine: Boolean,
|
||||
): EditText {
|
||||
val editText = AppCompatEditText(context)
|
||||
editText.inputType = inputType
|
||||
if (singleLine) {
|
||||
editText.setSingleLine()
|
||||
editText.imeOptions = EditorInfo.IME_ACTION_DONE
|
||||
}
|
||||
val layout = FrameLayout(context)
|
||||
val lp = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
|
||||
val horizontalMargin = context.resources.getDimensionPixelOffset(R.dimen.screen_padding)
|
||||
lp.setMargins(
|
||||
horizontalMargin,
|
||||
context.resources.getDimensionPixelOffset(R.dimen.margin_small),
|
||||
horizontalMargin,
|
||||
0,
|
||||
)
|
||||
layout.addView(editText, lp)
|
||||
setView(layout)
|
||||
return editText
|
||||
}
|
||||
|
||||
fun <B : AlertDialog.Builder> B.setEditText(
|
||||
entries: List<CharSequence>,
|
||||
inputType: Int,
|
||||
singleLine: Boolean,
|
||||
): EditText {
|
||||
if (entries.isEmpty()) {
|
||||
return setEditText(inputType, singleLine)
|
||||
}
|
||||
val binding = ViewDialogAutocompleteBinding.inflate(LayoutInflater.from(context))
|
||||
binding.autoCompleteTextView.setAdapter(
|
||||
ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, entries),
|
||||
)
|
||||
binding.dropdown.setOnClickListener {
|
||||
binding.autoCompleteTextView.showDropDown()
|
||||
}
|
||||
binding.autoCompleteTextView.inputType = inputType
|
||||
if (singleLine) {
|
||||
binding.autoCompleteTextView.setSingleLine()
|
||||
binding.autoCompleteTextView.imeOptions = EditorInfo.IME_ACTION_DONE
|
||||
}
|
||||
setView(binding.root)
|
||||
return binding.autoCompleteTextView
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ import android.view.View
|
||||
import androidx.annotation.Px
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class SpacingItemDecoration(@Px private val spacing: Int) : RecyclerView.ItemDecoration() {
|
||||
class SpacingItemDecoration(
|
||||
@Px private val spacing: Int,
|
||||
private val withBottomPadding: Boolean,
|
||||
) : RecyclerView.ItemDecoration() {
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
@@ -13,6 +16,6 @@ class SpacingItemDecoration(@Px private val spacing: Int) : RecyclerView.ItemDec
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State,
|
||||
) {
|
||||
outRect.set(spacing, spacing, spacing, spacing)
|
||||
outRect.set(spacing, spacing, spacing, if (withBottomPadding) spacing else 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(),
|
||||
OnApplyWindowInsetsListener,
|
||||
ExceptionResolver.Host {
|
||||
OnApplyWindowInsetsListener {
|
||||
|
||||
private var waitingForDismissAllowingStateLoss = false
|
||||
private var isFitToContentsDisabled = false
|
||||
|
||||
@@ -56,6 +56,11 @@ class ChipsView @JvmOverloads constructor(
|
||||
val data = it.tag
|
||||
onChipCloseClickListener?.onChipCloseClick(chip, data) ?: onChipClickListener?.onChipClick(chip, data)
|
||||
}
|
||||
private val chipOnLongClickListener = OnLongClickListener {
|
||||
val chip = it as Chip
|
||||
val data = it.tag
|
||||
onChipLongClickListener?.onChipLongClick(chip, data) ?: false
|
||||
}
|
||||
private val chipStyle: Int
|
||||
private val iconsVisible: Boolean
|
||||
var onChipClickListener: OnChipClickListener? = null
|
||||
@@ -66,6 +71,8 @@ class ChipsView @JvmOverloads constructor(
|
||||
}
|
||||
var onChipCloseClickListener: OnChipCloseClickListener? = null
|
||||
|
||||
var onChipLongClickListener: OnChipLongClickListener? = null
|
||||
|
||||
init {
|
||||
val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0)
|
||||
chipStyle = ta.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip)
|
||||
@@ -145,6 +152,7 @@ class ChipsView @JvmOverloads constructor(
|
||||
setOnCloseIconClickListener(chipOnCloseListener)
|
||||
setEnsureMinTouchTargetSize(false)
|
||||
setOnClickListener(chipOnClickListener)
|
||||
setOnLongClickListener(chipOnLongClickListener)
|
||||
isElegantTextHeight = false
|
||||
}
|
||||
|
||||
@@ -276,4 +284,9 @@ class ChipsView @JvmOverloads constructor(
|
||||
|
||||
fun onChipCloseClick(chip: Chip, data: Any?)
|
||||
}
|
||||
|
||||
fun interface OnChipLongClickListener {
|
||||
|
||||
fun onChipLongClick(chip: Chip, data: Any?): Boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.koitharu.kotatsu.core.ui.widgets
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.widget.FrameLayout
|
||||
|
||||
class TouchBlockLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : FrameLayout(context, attrs) {
|
||||
|
||||
var isTouchEventsAllowed = true
|
||||
|
||||
override fun onInterceptTouchEvent(
|
||||
ev: MotionEvent?
|
||||
): Boolean = if (isTouchEventsAllowed) {
|
||||
super.onInterceptTouchEvent(ev)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import androidx.work.impl.foreground.SystemForegroundService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* Workaround for issue
|
||||
* https://issuetracker.google.com/issues/270245927
|
||||
* https://issuetracker.google.com/issues/280504155
|
||||
*/
|
||||
class WorkServiceStopHelper(
|
||||
private val workManagerProvider: Provider<WorkManager>,
|
||||
) {
|
||||
|
||||
fun setup() {
|
||||
processLifecycleScope.launch(Dispatchers.Default) {
|
||||
workManagerProvider.get()
|
||||
.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING))
|
||||
.map { it.isEmpty() }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest {
|
||||
if (it) {
|
||||
delay(1_000)
|
||||
stopWorkerService()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
private fun stopWorkerService() {
|
||||
SystemForegroundService.getInstance()?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.exceptions.CaughtException
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
|
||||
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
|
||||
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
||||
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
|
||||
@@ -62,216 +63,219 @@ private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
|
||||
private val FNFE_MESSAGE_REGEX = Regex("^(/[^\\s:]+)?.+?\\s([A-Z]{2,6})?\\s.+$")
|
||||
|
||||
fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessageOrNull(resources)
|
||||
?: resources.getString(R.string.error_occurred)
|
||||
?: resources.getString(R.string.error_occurred)
|
||||
|
||||
private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) {
|
||||
is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message
|
||||
is CaughtException -> cause.getDisplayMessageOrNull(resources)
|
||||
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
|
||||
is ScrobblerAuthRequiredException -> resources.getString(
|
||||
R.string.scrobbler_auth_required,
|
||||
resources.getString(scrobbler.titleResId),
|
||||
)
|
||||
is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message
|
||||
is CaughtException -> cause.getDisplayMessageOrNull(resources)
|
||||
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
|
||||
is ScrobblerAuthRequiredException -> resources.getString(
|
||||
R.string.scrobbler_auth_required,
|
||||
resources.getString(scrobbler.titleResId),
|
||||
)
|
||||
|
||||
is AuthRequiredException -> resources.getString(R.string.auth_required)
|
||||
is InteractiveActionRequiredException -> resources.getString(R.string.additional_action_required)
|
||||
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message)
|
||||
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
|
||||
is ActivityNotFoundException,
|
||||
is UnsupportedOperationException,
|
||||
-> resources.getString(R.string.operation_not_supported)
|
||||
is AuthRequiredException -> resources.getString(R.string.auth_required)
|
||||
is InteractiveActionRequiredException -> resources.getString(R.string.additional_action_required)
|
||||
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message)
|
||||
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
|
||||
is ActivityNotFoundException,
|
||||
is UnsupportedOperationException,
|
||||
-> resources.getString(R.string.operation_not_supported)
|
||||
|
||||
is TooManyRequestExceptions -> {
|
||||
val delay = getRetryDelay()
|
||||
val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) {
|
||||
resources.formatDurationShort(delay)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (formattedTime != null) {
|
||||
resources.getString(R.string.too_many_requests_message_retry, formattedTime)
|
||||
} else {
|
||||
resources.getString(R.string.too_many_requests_message)
|
||||
}
|
||||
}
|
||||
is TooManyRequestExceptions -> {
|
||||
val delay = getRetryDelay()
|
||||
val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) {
|
||||
resources.formatDurationShort(delay)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (formattedTime != null) {
|
||||
resources.getString(R.string.too_many_requests_message_retry, formattedTime)
|
||||
} else {
|
||||
resources.getString(R.string.too_many_requests_message)
|
||||
}
|
||||
}
|
||||
|
||||
is ZipException -> resources.getString(R.string.error_corrupted_zip, this.message.orEmpty())
|
||||
is SQLiteFullException -> resources.getString(R.string.error_no_space_left)
|
||||
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
||||
is FileNotFoundException -> parseMessage(resources) ?: message
|
||||
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
||||
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
|
||||
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
||||
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
|
||||
is SyncApiException,
|
||||
is ContentUnavailableException -> message
|
||||
is ZipException -> resources.getString(R.string.error_corrupted_zip, this.message.orEmpty())
|
||||
is SQLiteFullException -> resources.getString(R.string.error_no_space_left)
|
||||
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
||||
is FileNotFoundException -> parseMessage(resources) ?: message
|
||||
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
||||
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
|
||||
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
||||
is EmptyMangaException -> reason?.let { resources.getString(it.msgResId) } ?: cause?.getDisplayMessage(resources)
|
||||
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
|
||||
is SyncApiException,
|
||||
is ContentUnavailableException -> message
|
||||
|
||||
is ParseException -> shortMessage
|
||||
is ConnectException,
|
||||
is UnknownHostException,
|
||||
is NoRouteToHostException,
|
||||
is SocketTimeoutException -> resources.getString(R.string.network_error)
|
||||
is ParseException -> shortMessage
|
||||
is ConnectException,
|
||||
is UnknownHostException,
|
||||
is NoRouteToHostException,
|
||||
is SocketTimeoutException -> resources.getString(R.string.network_error)
|
||||
|
||||
is ImageDecodeException -> {
|
||||
val type = format?.substringBefore('/')
|
||||
val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) }
|
||||
if (type.isNullOrEmpty() || type == "image") {
|
||||
resources.getString(R.string.error_image_format, formatString)
|
||||
} else {
|
||||
resources.getString(R.string.error_not_image, formatString)
|
||||
}
|
||||
}
|
||||
is ImageDecodeException -> {
|
||||
val type = format?.substringBefore('/')
|
||||
val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) }
|
||||
if (type.isNullOrEmpty() || type == "image") {
|
||||
resources.getString(R.string.error_image_format, formatString)
|
||||
} else {
|
||||
resources.getString(R.string.error_not_image, formatString)
|
||||
}
|
||||
}
|
||||
|
||||
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
|
||||
is IncompatiblePluginException -> {
|
||||
cause?.getDisplayMessageOrNull(resources)?.let {
|
||||
resources.getString(R.string.plugin_incompatible_with_cause, it)
|
||||
} ?: resources.getString(R.string.plugin_incompatible)
|
||||
}
|
||||
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
|
||||
is IncompatiblePluginException -> {
|
||||
cause?.getDisplayMessageOrNull(resources)?.let {
|
||||
resources.getString(R.string.plugin_incompatible_with_cause, it)
|
||||
} ?: resources.getString(R.string.plugin_incompatible)
|
||||
}
|
||||
|
||||
is WrongPasswordException -> resources.getString(R.string.wrong_password)
|
||||
is NotFoundException -> resources.getString(R.string.not_found_404)
|
||||
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
|
||||
is WrongPasswordException -> resources.getString(R.string.wrong_password)
|
||||
is NotFoundException -> resources.getString(R.string.not_found_404)
|
||||
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
|
||||
|
||||
is HttpException -> getHttpDisplayMessage(response.code, resources)
|
||||
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
|
||||
is HttpException -> getHttpDisplayMessage(response.code, resources)
|
||||
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
|
||||
|
||||
else -> mapDisplayMessage(message, resources) ?: message
|
||||
else -> mapDisplayMessage(message, resources) ?: message
|
||||
}.takeUnless { it.isNullOrBlank() }
|
||||
|
||||
@DrawableRes
|
||||
fun Throwable.getDisplayIcon(): Int = when (this) {
|
||||
is AuthRequiredException -> R.drawable.ic_auth_key_large
|
||||
is CloudFlareProtectedException -> R.drawable.ic_bot_large
|
||||
is UnknownHostException,
|
||||
is SocketTimeoutException,
|
||||
is ConnectException,
|
||||
is NoRouteToHostException,
|
||||
is ProtocolException -> R.drawable.ic_plug_large
|
||||
is AuthRequiredException -> R.drawable.ic_auth_key_large
|
||||
is CloudFlareProtectedException -> R.drawable.ic_bot_large
|
||||
is UnknownHostException,
|
||||
is SocketTimeoutException,
|
||||
is ConnectException,
|
||||
is NoRouteToHostException,
|
||||
is ProtocolException -> R.drawable.ic_plug_large
|
||||
|
||||
is CloudFlareBlockedException -> R.drawable.ic_denied_large
|
||||
is CloudFlareBlockedException -> R.drawable.ic_denied_large
|
||||
|
||||
is InteractiveActionRequiredException -> R.drawable.ic_interaction_large
|
||||
else -> R.drawable.ic_error_large
|
||||
is InteractiveActionRequiredException -> R.drawable.ic_interaction_large
|
||||
else -> R.drawable.ic_error_large
|
||||
}
|
||||
|
||||
fun Throwable.getCauseUrl(): String? = when (this) {
|
||||
is ParseException -> url
|
||||
is NotFoundException -> url
|
||||
is TooManyRequestExceptions -> url
|
||||
is CaughtException -> cause.getCauseUrl()
|
||||
is WrapperIOException -> cause.getCauseUrl()
|
||||
is NoDataReceivedException -> url
|
||||
is CloudFlareBlockedException -> url
|
||||
is CloudFlareProtectedException -> url
|
||||
is InteractiveActionRequiredException -> url
|
||||
is HttpStatusException -> url
|
||||
is HttpException -> (response.delegate as? Response)?.request?.url?.toString()
|
||||
else -> null
|
||||
is ParseException -> url
|
||||
is NotFoundException -> url
|
||||
is TooManyRequestExceptions -> url
|
||||
is CaughtException -> cause.getCauseUrl()
|
||||
is WrapperIOException -> cause.getCauseUrl()
|
||||
is NoDataReceivedException -> url
|
||||
is CloudFlareBlockedException -> url
|
||||
is CloudFlareProtectedException -> url
|
||||
is InteractiveActionRequiredException -> url
|
||||
is HttpStatusException -> url
|
||||
is UnsupportedSourceException -> manga?.publicUrl?.takeIf { it.isHttpUrl() }
|
||||
is EmptyMangaException -> manga.publicUrl.takeIf { it.isHttpUrl() }
|
||||
is HttpException -> (response.delegate as? Response)?.request?.url?.toString()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) {
|
||||
HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404)
|
||||
HttpURLConnection.HTTP_FORBIDDEN -> resources.getString(R.string.access_denied_403)
|
||||
HttpURLConnection.HTTP_GATEWAY_TIMEOUT -> resources.getString(R.string.network_unavailable)
|
||||
in 500..599 -> resources.getString(R.string.server_error, statusCode)
|
||||
else -> null
|
||||
HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404)
|
||||
HttpURLConnection.HTTP_FORBIDDEN -> resources.getString(R.string.access_denied_403)
|
||||
HttpURLConnection.HTTP_GATEWAY_TIMEOUT -> resources.getString(R.string.network_unavailable)
|
||||
in 500..599 -> resources.getString(R.string.server_error, statusCode)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun mapDisplayMessage(msg: String?, resources: Resources): String? = when {
|
||||
msg.isNullOrEmpty() -> null
|
||||
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
|
||||
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
|
||||
msg == MSG_CONNECTION_RESET -> resources.getString(R.string.error_connection_reset)
|
||||
msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
|
||||
msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
|
||||
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
|
||||
msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported)
|
||||
msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported)
|
||||
else -> null
|
||||
msg.isNullOrEmpty() -> null
|
||||
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
|
||||
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
|
||||
msg == MSG_CONNECTION_RESET -> resources.getString(R.string.error_connection_reset)
|
||||
msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
|
||||
msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
|
||||
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
|
||||
msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported)
|
||||
msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported)
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun Throwable.isReportable(): Boolean {
|
||||
if (this is Error) {
|
||||
return true
|
||||
}
|
||||
if (this is CaughtException) {
|
||||
return cause.isReportable()
|
||||
}
|
||||
if (this is WrapperIOException) {
|
||||
return cause.isReportable()
|
||||
}
|
||||
if (ExceptionResolver.canResolve(this)) {
|
||||
return false
|
||||
}
|
||||
if (this is ParseException
|
||||
|| this.isNetworkError()
|
||||
|| this is CloudFlareBlockedException
|
||||
|| this is CloudFlareProtectedException
|
||||
|| this is BadBackupFormatException
|
||||
|| this is WrongPasswordException
|
||||
|| this is TooManyRequestExceptions
|
||||
|| this is HttpStatusException
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
if (this is Error) {
|
||||
return true
|
||||
}
|
||||
if (this is CaughtException) {
|
||||
return cause.isReportable()
|
||||
}
|
||||
if (this is WrapperIOException) {
|
||||
return cause.isReportable()
|
||||
}
|
||||
if (ExceptionResolver.canResolve(this)) {
|
||||
return false
|
||||
}
|
||||
if (this is ParseException
|
||||
|| this.isNetworkError()
|
||||
|| this is CloudFlareBlockedException
|
||||
|| this is CloudFlareProtectedException
|
||||
|| this is BadBackupFormatException
|
||||
|| this is WrongPasswordException
|
||||
|| this is TooManyRequestExceptions
|
||||
|| this is HttpStatusException
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun Throwable.isNetworkError(): Boolean {
|
||||
return this is UnknownHostException
|
||||
|| this is SocketTimeoutException
|
||||
|| this is StreamResetException
|
||||
|| this is SocketException
|
||||
|| this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT
|
||||
return this is UnknownHostException
|
||||
|| this is SocketTimeoutException
|
||||
|| this is StreamResetException
|
||||
|| this is SocketException
|
||||
|| this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT
|
||||
}
|
||||
|
||||
fun Throwable.report(silent: Boolean = false) {
|
||||
val exception = CaughtException(this)
|
||||
if (!silent) {
|
||||
exception.sendWithAcra()
|
||||
} else if (!BuildConfig.DEBUG) {
|
||||
exception.sendSilentlyWithAcra()
|
||||
}
|
||||
val exception = CaughtException(this)
|
||||
if (!silent) {
|
||||
exception.sendWithAcra()
|
||||
} else if (!BuildConfig.DEBUG) {
|
||||
exception.sendSilentlyWithAcra()
|
||||
}
|
||||
}
|
||||
|
||||
fun Throwable.isWebViewUnavailable(): Boolean {
|
||||
val trace = stackTraceToString()
|
||||
return trace.contains("android.webkit.WebView.<init>")
|
||||
val trace = stackTraceToString()
|
||||
return trace.contains("android.webkit.WebView.<init>")
|
||||
}
|
||||
|
||||
@Suppress("FunctionName")
|
||||
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)
|
||||
|
||||
fun FileNotFoundException.getFile(): File? {
|
||||
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
||||
return groups.getOrNull(1)?.let { File(it) }
|
||||
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
||||
return groups.getOrNull(1)?.let { File(it) }
|
||||
}
|
||||
|
||||
fun FileNotFoundException.parseMessage(resources: Resources): String? {
|
||||
/*
|
||||
Examples:
|
||||
/storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system)
|
||||
/storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory)
|
||||
/storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error)
|
||||
*/
|
||||
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
||||
val path = groups.getOrNull(1)
|
||||
val error = groups.getOrNull(2)
|
||||
val baseMessageIs = when (error) {
|
||||
"EROFS" -> R.string.no_write_permission_to_file
|
||||
"ENOENT" -> R.string.file_not_found
|
||||
else -> return null
|
||||
}
|
||||
return if (path.isNullOrEmpty()) {
|
||||
resources.getString(baseMessageIs)
|
||||
} else {
|
||||
resources.getString(
|
||||
R.string.inline_preference_pattern,
|
||||
resources.getString(baseMessageIs),
|
||||
path,
|
||||
)
|
||||
}
|
||||
/*
|
||||
Examples:
|
||||
/storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system)
|
||||
/storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory)
|
||||
/storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error)
|
||||
*/
|
||||
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
||||
val path = groups.getOrNull(1)
|
||||
val error = groups.getOrNull(2)
|
||||
val baseMessageIs = when (error) {
|
||||
"EROFS" -> R.string.no_write_permission_to_file
|
||||
"ENOENT" -> R.string.file_not_found
|
||||
else -> return null
|
||||
}
|
||||
return if (path.isNullOrEmpty()) {
|
||||
resources.getString(baseMessageIs)
|
||||
} else {
|
||||
resources.getString(
|
||||
R.string.inline_preference_pattern,
|
||||
resources.getString(baseMessageIs),
|
||||
path,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,111 +7,115 @@ import org.koitharu.kotatsu.core.ui.model.MangaOverride
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.reader.data.filterChapters
|
||||
import java.util.Locale
|
||||
|
||||
data class MangaDetails(
|
||||
private val manga: Manga,
|
||||
private val localManga: LocalManga?,
|
||||
private val override: MangaOverride?,
|
||||
val description: CharSequence?,
|
||||
val isLoaded: Boolean,
|
||||
private val manga: Manga,
|
||||
private val localManga: LocalManga?,
|
||||
private val override: MangaOverride?,
|
||||
val description: CharSequence?,
|
||||
val isLoaded: Boolean,
|
||||
) {
|
||||
|
||||
constructor(manga: Manga) : this(
|
||||
manga = manga,
|
||||
localManga = null,
|
||||
override = null,
|
||||
description = null,
|
||||
isLoaded = false,
|
||||
)
|
||||
constructor(manga: Manga) : this(
|
||||
manga = manga,
|
||||
localManga = null,
|
||||
override = null,
|
||||
description = null,
|
||||
isLoaded = false,
|
||||
)
|
||||
|
||||
val id: Long
|
||||
get() = manga.id
|
||||
val id: Long
|
||||
get() = manga.id
|
||||
|
||||
val allChapters: List<MangaChapter> by lazy { mergeChapters() }
|
||||
val allChapters: List<MangaChapter> by lazy { mergeChapters() }
|
||||
|
||||
val chapters: Map<String?, List<MangaChapter>> by lazy {
|
||||
allChapters.groupBy { it.branch }
|
||||
}
|
||||
val chapters: Map<String?, List<MangaChapter>> by lazy {
|
||||
allChapters.groupBy { it.branch }
|
||||
}
|
||||
|
||||
val isLocal
|
||||
get() = manga.isLocal
|
||||
val isLocal
|
||||
get() = manga.isLocal
|
||||
|
||||
val local: LocalManga?
|
||||
get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null
|
||||
val local: LocalManga?
|
||||
get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null
|
||||
|
||||
val coverUrl: String?
|
||||
get() = override?.coverUrl
|
||||
.ifNullOrEmpty { manga.largeCoverUrl }
|
||||
.ifNullOrEmpty { manga.coverUrl }
|
||||
.ifNullOrEmpty { localManga?.manga?.coverUrl }
|
||||
?.nullIfEmpty()
|
||||
val coverUrl: String?
|
||||
get() = override?.coverUrl
|
||||
.ifNullOrEmpty { manga.largeCoverUrl }
|
||||
.ifNullOrEmpty { manga.coverUrl }
|
||||
.ifNullOrEmpty { localManga?.manga?.coverUrl }
|
||||
?.nullIfEmpty()
|
||||
|
||||
private val mergedManga by lazy {
|
||||
if (localManga == null) {
|
||||
// fast path
|
||||
manga.withOverride(override)
|
||||
} else {
|
||||
manga.copy(
|
||||
title = override?.title.ifNullOrEmpty { manga.title },
|
||||
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
|
||||
largeCoverUrl = override?.coverUrl.ifNullOrEmpty { manga.largeCoverUrl },
|
||||
contentRating = override?.contentRating ?: manga.contentRating,
|
||||
chapters = allChapters,
|
||||
)
|
||||
}
|
||||
}
|
||||
val isRestricted: Boolean
|
||||
get() = manga.state == MangaState.RESTRICTED
|
||||
|
||||
fun toManga() = mergedManga
|
||||
private val mergedManga by lazy {
|
||||
if (localManga == null) {
|
||||
// fast path
|
||||
manga.withOverride(override)
|
||||
} else {
|
||||
manga.copy(
|
||||
title = override?.title.ifNullOrEmpty { manga.title },
|
||||
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
|
||||
largeCoverUrl = override?.coverUrl.ifNullOrEmpty { manga.largeCoverUrl },
|
||||
contentRating = override?.contentRating ?: manga.contentRating,
|
||||
chapters = allChapters,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getLocale(): Locale? {
|
||||
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
|
||||
return it
|
||||
}
|
||||
return manga.source.getLocale()
|
||||
}
|
||||
fun toManga() = mergedManga
|
||||
|
||||
fun filterChapters(branch: String?) = copy(
|
||||
manga = manga.filterChapters(branch),
|
||||
localManga = localManga?.run {
|
||||
copy(manga = manga.filterChapters(branch))
|
||||
},
|
||||
)
|
||||
fun getLocale(): Locale? {
|
||||
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
|
||||
return it
|
||||
}
|
||||
return manga.source.getLocale()
|
||||
}
|
||||
|
||||
private fun mergeChapters(): List<MangaChapter> {
|
||||
val chapters = manga.chapters
|
||||
val localChapters = local?.manga?.chapters.orEmpty()
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
return localChapters
|
||||
}
|
||||
val localMap = if (localChapters.isNotEmpty()) {
|
||||
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val result = ArrayList<MangaChapter>(chapters.size)
|
||||
for (chapter in chapters) {
|
||||
val local = localMap?.remove(chapter.id)
|
||||
result += local ?: chapter
|
||||
}
|
||||
if (!localMap.isNullOrEmpty()) {
|
||||
result.addAll(localMap.values)
|
||||
}
|
||||
return result
|
||||
}
|
||||
fun filterChapters(branch: String?) = copy(
|
||||
manga = manga.filterChapters(branch),
|
||||
localManga = localManga?.run {
|
||||
copy(manga = manga.filterChapters(branch))
|
||||
},
|
||||
)
|
||||
|
||||
private fun findAppropriateLocale(name: String?): Locale? {
|
||||
if (name.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
return Locale.getAvailableLocales().find { lc ->
|
||||
name.contains(lc.getDisplayName(lc), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
private fun mergeChapters(): List<MangaChapter> {
|
||||
val chapters = manga.chapters
|
||||
val localChapters = local?.manga?.chapters.orEmpty()
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
return localChapters
|
||||
}
|
||||
val localMap = if (localChapters.isNotEmpty()) {
|
||||
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val result = ArrayList<MangaChapter>(chapters.size)
|
||||
for (chapter in chapters) {
|
||||
val local = localMap?.remove(chapter.id)
|
||||
result += local ?: chapter
|
||||
}
|
||||
if (!localMap.isNullOrEmpty()) {
|
||||
result.addAll(localMap.values)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun findAppropriateLocale(name: String?): Locale? {
|
||||
if (name.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
return Locale.getAvailableLocales().find { lc ->
|
||||
name.contains(lc.getDisplayName(lc), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package org.koitharu.kotatsu.details.domain
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
@@ -34,12 +34,17 @@ class ProgressUpdateUseCase @Inject constructor(
|
||||
}
|
||||
val chapter = details.findChapterById(history.chapterId) ?: return PROGRESS_NONE
|
||||
val chapters = details.getChapters(chapter.branch)
|
||||
val chapterRepo = if (repo.source == chapter.source) {
|
||||
repo
|
||||
} else {
|
||||
mangaRepositoryFactory.create(chapter.source)
|
||||
}
|
||||
val chaptersCount = chapters.size
|
||||
if (chaptersCount == 0) {
|
||||
return PROGRESS_NONE
|
||||
}
|
||||
val chapterIndex = chapters.indexOfFirst { x -> x.id == history.chapterId }
|
||||
val pagesCount = repo.getPages(chapter).size
|
||||
val pagesCount = chapterRepo.getPages(chapter).size
|
||||
if (pagesCount == 0) {
|
||||
return PROGRESS_NONE
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ class ReadingTimeUseCase @Inject constructor(
|
||||
// Impossible task, I guess. Good luck on this.
|
||||
var averageTimeSec: Int = 20 /* pages */ * getSecondsPerPage(manga.id) * chapters.size
|
||||
if (isOnHistoryBranch) {
|
||||
averageTimeSec = (averageTimeSec * (1f - checkNotNull(history).percent)).roundToInt()
|
||||
averageTimeSec = (averageTimeSec * (1f - history.percent)).roundToInt()
|
||||
}
|
||||
if (averageTimeSec < 60) {
|
||||
return null
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import android.app.assist.AssistContent
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.SpannedString
|
||||
import android.view.Gravity
|
||||
|
||||
@@ -140,6 +140,7 @@ class DetailsViewModel @Inject constructor(
|
||||
get() = scrobblers.any { it.isEnabled }
|
||||
|
||||
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
|
||||
.withErrorHandling()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
val relatedManga: StateFlow<List<MangaListModel>> = manga.mapLatest {
|
||||
|
||||
@@ -99,10 +99,11 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(),
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets
|
||||
|
||||
override fun onStateChanged(sheet: View, newState: Int) {
|
||||
if (newState == STATE_DRAGGING || newState == STATE_SETTLING) {
|
||||
return
|
||||
}
|
||||
val binding = viewBinding ?: return
|
||||
val binding = viewBinding ?: return
|
||||
binding.layoutTouchBlock.isTouchEventsAllowed = dialog != null || newState != STATE_COLLAPSED
|
||||
if (newState == STATE_DRAGGING || newState == STATE_SETTLING) {
|
||||
return
|
||||
}
|
||||
val isActionModeStarted = actionModeDelegate?.isActionModeStarted == true
|
||||
binding.toolbar.menuView?.isVisible = newState == STATE_EXPANDED && !isActionModeStarted
|
||||
binding.splitButtonRead.isVisible = newState != STATE_EXPANDED && !isActionModeStarted
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.details.ui.pager.chapters
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
@@ -11,6 +12,7 @@ import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.ui.list.BaseListSelectionCallback
|
||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.toCollection
|
||||
import org.koitharu.kotatsu.core.util.ext.toSet
|
||||
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
|
||||
@@ -78,11 +80,20 @@ class ChaptersSelectionCallback(
|
||||
ids.size == manga.chapters?.size -> viewModel.deleteLocal()
|
||||
else -> {
|
||||
LocalChaptersRemoveService.start(recyclerView.context, manga, ids.toSet())
|
||||
Snackbar.make(
|
||||
recyclerView,
|
||||
R.string.chapters_will_removed_background,
|
||||
Snackbar.LENGTH_LONG,
|
||||
).show()
|
||||
try {
|
||||
Snackbar.make(
|
||||
recyclerView,
|
||||
R.string.chapters_will_removed_background,
|
||||
Snackbar.LENGTH_LONG,
|
||||
).show()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.printStackTraceDebug()
|
||||
Toast.makeText(
|
||||
recyclerView.context,
|
||||
R.string.chapters_will_removed_background,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
mode?.finish()
|
||||
|
||||
@@ -105,7 +105,14 @@ class PagesViewModel @Inject constructor(
|
||||
chaptersLoader.peekChapter(it) != null
|
||||
} ?: state.details.allChapters.firstOrNull()?.id ?: return
|
||||
if (!chaptersLoader.hasPages(initialChapterId)) {
|
||||
chaptersLoader.loadSingleChapter(initialChapterId)
|
||||
var hasPages = chaptersLoader.loadSingleChapter(initialChapterId)
|
||||
while (!hasPages) {
|
||||
if (chaptersLoader.loadPrevNextChapter(state.details, initialChapterId, isNext = true)) {
|
||||
hasPages = chaptersLoader.snapshot().isNotEmpty()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
updateList(state.readerState)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,9 @@ class MangaSourcesRepository @Inject constructor(
|
||||
get() = db.getSourcesDao()
|
||||
|
||||
val allMangaSources: Set<MangaParserSource> = Collections.unmodifiableSet(
|
||||
EnumSet.allOf(MangaParserSource::class.java)
|
||||
EnumSet.noneOf<MangaParserSource>(MangaParserSource::class.java).also {
|
||||
MangaParserSource.entries.filterNotTo(it, MangaParserSource::isBroken)
|
||||
}
|
||||
)
|
||||
|
||||
suspend fun getEnabledSources(): List<MangaSource> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.explore.ui.adapter
|
||||
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.TooltipCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.text.bold
|
||||
import androidx.core.text.buildSpannedString
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package org.koitharu.kotatsu.filter.data
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.builtins.SetSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.element
|
||||
import kotlinx.serialization.encoding.CompositeDecoder
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.encoding.decodeStructure
|
||||
import kotlinx.serialization.encoding.encodeStructure
|
||||
import kotlinx.serialization.serializer
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.util.ext.toLocaleOrNull
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.Demographic
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import java.util.Locale
|
||||
|
||||
object MangaListFilterSerializer : KSerializer<MangaListFilter> {
|
||||
|
||||
override val descriptor: SerialDescriptor =
|
||||
buildClassSerialDescriptor(MangaListFilter::class.java.name) {
|
||||
element<String?>("query", isOptional = true)
|
||||
element(
|
||||
elementName = "tags",
|
||||
descriptor = SetSerializer(MangaTagSerializer).descriptor,
|
||||
isOptional = true,
|
||||
)
|
||||
element(
|
||||
elementName = "tagsExclude",
|
||||
descriptor = SetSerializer(MangaTagSerializer).descriptor,
|
||||
isOptional = true,
|
||||
)
|
||||
element<String?>("locale", isOptional = true)
|
||||
element<String?>("originalLocale", isOptional = true)
|
||||
element<Set<MangaState>>("states", isOptional = true)
|
||||
element<Set<ContentRating>>("contentRating", isOptional = true)
|
||||
element<Set<ContentType>>("types", isOptional = true)
|
||||
element<Set<Demographic>>("demographics", isOptional = true)
|
||||
element<Int>("year", isOptional = true)
|
||||
element<Int>("yearFrom", isOptional = true)
|
||||
element<Int>("yearTo", isOptional = true)
|
||||
element<String?>("author", isOptional = true)
|
||||
}
|
||||
|
||||
override fun serialize(
|
||||
encoder: Encoder,
|
||||
value: MangaListFilter
|
||||
) = encoder.encodeStructure(descriptor) {
|
||||
encodeNullableSerializableElement(descriptor, 0, String.serializer(), value.query)
|
||||
encodeSerializableElement(descriptor, 1, SetSerializer(MangaTagSerializer), value.tags)
|
||||
encodeSerializableElement(descriptor, 2, SetSerializer(MangaTagSerializer), value.tagsExclude)
|
||||
encodeNullableSerializableElement(descriptor, 3, String.serializer(), value.locale?.toLanguageTag())
|
||||
encodeNullableSerializableElement(descriptor, 4, String.serializer(), value.originalLocale?.toLanguageTag())
|
||||
encodeSerializableElement(descriptor, 5, SetSerializer(serializer()), value.states)
|
||||
encodeSerializableElement(descriptor, 6, SetSerializer(serializer()), value.contentRating)
|
||||
encodeSerializableElement(descriptor, 7, SetSerializer(serializer()), value.types)
|
||||
encodeSerializableElement(descriptor, 8, SetSerializer(serializer()), value.demographics)
|
||||
encodeIntElement(descriptor, 9, value.year)
|
||||
encodeIntElement(descriptor, 10, value.yearFrom)
|
||||
encodeIntElement(descriptor, 11, value.yearTo)
|
||||
encodeNullableSerializableElement(descriptor, 12, String.serializer(), value.author)
|
||||
}
|
||||
|
||||
override fun deserialize(
|
||||
decoder: Decoder
|
||||
): MangaListFilter = decoder.decodeStructure(descriptor) {
|
||||
var query: String? = MangaListFilter.EMPTY.query
|
||||
var tags: Set<MangaTag> = MangaListFilter.EMPTY.tags
|
||||
var tagsExclude: Set<MangaTag> = MangaListFilter.EMPTY.tagsExclude
|
||||
var locale: Locale? = MangaListFilter.EMPTY.locale
|
||||
var originalLocale: Locale? = MangaListFilter.EMPTY.originalLocale
|
||||
var states: Set<MangaState> = MangaListFilter.EMPTY.states
|
||||
var contentRating: Set<ContentRating> = MangaListFilter.EMPTY.contentRating
|
||||
var types: Set<ContentType> = MangaListFilter.EMPTY.types
|
||||
var demographics: Set<Demographic> = MangaListFilter.EMPTY.demographics
|
||||
var year: Int = MangaListFilter.EMPTY.year
|
||||
var yearFrom: Int = MangaListFilter.EMPTY.yearFrom
|
||||
var yearTo: Int = MangaListFilter.EMPTY.yearTo
|
||||
var author: String? = MangaListFilter.EMPTY.author
|
||||
|
||||
while (true) {
|
||||
when (decodeElementIndex(descriptor)) {
|
||||
0 -> query = decodeNullableSerializableElement(descriptor, 0, serializer<String>())
|
||||
1 -> tags = decodeSerializableElement(descriptor, 1, SetSerializer(MangaTagSerializer))
|
||||
2 -> tagsExclude = decodeSerializableElement(descriptor, 2, SetSerializer(MangaTagSerializer))
|
||||
3 -> locale = decodeNullableSerializableElement(descriptor, 3, serializer<String>())?.toLocaleOrNull()
|
||||
4 -> originalLocale =
|
||||
decodeNullableSerializableElement(descriptor, 4, serializer<String>())?.toLocaleOrNull()
|
||||
|
||||
5 -> states = decodeSerializableElement(descriptor, 5, SetSerializer(serializer()))
|
||||
6 -> contentRating = decodeSerializableElement(descriptor, 6, SetSerializer(serializer()))
|
||||
7 -> types = decodeSerializableElement(descriptor, 7, SetSerializer(serializer()))
|
||||
8 -> demographics = decodeSerializableElement(descriptor, 8, SetSerializer(serializer()))
|
||||
9 -> year = decodeIntElement(descriptor, 9)
|
||||
10 -> yearFrom = decodeIntElement(descriptor, 10)
|
||||
11 -> yearTo = decodeIntElement(descriptor, 11)
|
||||
12 -> author = decodeNullableSerializableElement(descriptor, 12, serializer<String>())
|
||||
CompositeDecoder.DECODE_DONE -> break
|
||||
}
|
||||
}
|
||||
|
||||
MangaListFilter(
|
||||
query = query,
|
||||
tags = tags,
|
||||
tagsExclude = tagsExclude,
|
||||
locale = locale,
|
||||
originalLocale = originalLocale,
|
||||
states = states,
|
||||
contentRating = contentRating,
|
||||
types = types,
|
||||
demographics = demographics,
|
||||
year = year,
|
||||
yearFrom = yearFrom,
|
||||
yearTo = yearTo,
|
||||
author = author,
|
||||
)
|
||||
}
|
||||
|
||||
private object MangaTagSerializer : KSerializer<MangaTag> {
|
||||
|
||||
override val descriptor: SerialDescriptor = buildClassSerialDescriptor(MangaTag::class.java.name) {
|
||||
element<String>("title")
|
||||
element<String>("key")
|
||||
element<String>("source")
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: MangaTag) = encoder.encodeStructure(descriptor) {
|
||||
encodeStringElement(descriptor, 0, value.title)
|
||||
encodeStringElement(descriptor, 1, value.key)
|
||||
encodeStringElement(descriptor, 2, value.source.name)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): MangaTag = decoder.decodeStructure(descriptor) {
|
||||
var title: String? = null
|
||||
var key: String? = null
|
||||
var source: String? = null
|
||||
|
||||
while (true) {
|
||||
when (decodeElementIndex(descriptor)) {
|
||||
0 -> title = decodeStringElement(descriptor, 0)
|
||||
1 -> key = decodeStringElement(descriptor, 1)
|
||||
2 -> source = decodeStringElement(descriptor, 2)
|
||||
CompositeDecoder.DECODE_DONE -> break
|
||||
}
|
||||
}
|
||||
|
||||
MangaTag(
|
||||
title = title ?: error("Missing 'title' field"),
|
||||
key = key ?: error("Missing 'key' field"),
|
||||
source = MangaSource(source),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.koitharu.kotatsu.filter.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
|
||||
import org.koitharu.kotatsu.core.model.MangaSourceSerializer
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
@Serializable
|
||||
@JsonIgnoreUnknownKeys
|
||||
data class PersistableFilter(
|
||||
@SerialName("name")
|
||||
val name: String,
|
||||
@Serializable(with = MangaSourceSerializer::class)
|
||||
@SerialName("source")
|
||||
val source: MangaSource,
|
||||
@Serializable(with = MangaListFilterSerializer::class)
|
||||
@SerialName("filter")
|
||||
val filter: MangaListFilter,
|
||||
) {
|
||||
|
||||
val id: Int
|
||||
get() = name.hashCode()
|
||||
|
||||
companion object {
|
||||
|
||||
const val MAX_TITLE_LENGTH = 18
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package org.koitharu.kotatsu.filter.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import dagger.Reusable
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koitharu.kotatsu.core.util.ext.observeChanges
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class SavedFiltersRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
fun observeAll(source: MangaSource): Flow<List<PersistableFilter>> = getPrefs(source).observeChanges()
|
||||
.onStart { emit(null) }
|
||||
.map {
|
||||
getAll(source)
|
||||
}.distinctUntilChanged()
|
||||
.flowOn(Dispatchers.Default)
|
||||
|
||||
suspend fun getAll(source: MangaSource): List<PersistableFilter> = withContext(Dispatchers.Default) {
|
||||
val prefs = getPrefs(source)
|
||||
val keys = prefs.all.keys.filter { it.startsWith(FILTER_PREFIX) }
|
||||
keys.mapNotNull { key ->
|
||||
val value = prefs.getString(key, null) ?: return@mapNotNull null
|
||||
try {
|
||||
Json.decodeFromString(value)
|
||||
} catch (e: SerializationException) {
|
||||
e.printStackTraceDebug()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun save(
|
||||
source: MangaSource,
|
||||
name: String,
|
||||
filter: MangaListFilter,
|
||||
): PersistableFilter = withContext(Dispatchers.Default) {
|
||||
val persistableFilter = PersistableFilter(
|
||||
name = name,
|
||||
source = source,
|
||||
filter = filter,
|
||||
)
|
||||
persist(persistableFilter)
|
||||
persistableFilter
|
||||
}
|
||||
|
||||
suspend fun save(
|
||||
filter: PersistableFilter,
|
||||
) = withContext(Dispatchers.Default) {
|
||||
persist(filter)
|
||||
}
|
||||
|
||||
suspend fun rename(source: MangaSource, id: Int, newName: String) = withContext(Dispatchers.Default) {
|
||||
val filter = load(source, id) ?: return@withContext
|
||||
val newFilter = filter.copy(name = newName)
|
||||
val prefs = getPrefs(source)
|
||||
prefs.edit(commit = true) {
|
||||
remove(key(id))
|
||||
putString(key(newFilter.id), Json.encodeToString(newFilter))
|
||||
}
|
||||
newFilter
|
||||
}
|
||||
|
||||
suspend fun delete(source: MangaSource, id: Int) = withContext(Dispatchers.Default) {
|
||||
val prefs = getPrefs(source)
|
||||
prefs.edit(commit = true) {
|
||||
remove(key(id))
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist(persistableFilter: PersistableFilter) {
|
||||
val prefs = getPrefs(persistableFilter.source)
|
||||
val json = Json.encodeToString(persistableFilter)
|
||||
prefs.edit(commit = true) {
|
||||
putString(key(persistableFilter.id), json)
|
||||
}
|
||||
}
|
||||
|
||||
private fun load(source: MangaSource, id: Int): PersistableFilter? {
|
||||
val prefs = getPrefs(source)
|
||||
val json = prefs.getString(key(id), null) ?: return null
|
||||
return try {
|
||||
Json.decodeFromString<PersistableFilter>(json)
|
||||
} catch (e: SerializationException) {
|
||||
e.printStackTraceDebug()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPrefs(source: MangaSource): SharedPreferences {
|
||||
val key = source.name.replace(File.separatorChar, '$')
|
||||
return context.getSharedPreferences(key, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val FILTER_PREFIX = "__pf_"
|
||||
|
||||
fun key(id: Int) = FILTER_PREFIX + id
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
@@ -25,6 +26,8 @@ import org.koitharu.kotatsu.core.util.ext.asFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
|
||||
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
|
||||
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
|
||||
import org.koitharu.kotatsu.filter.data.PersistableFilter
|
||||
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||
import org.koitharu.kotatsu.filter.ui.tags.TagTitleComparator
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
@@ -48,469 +51,502 @@ import javax.inject.Inject
|
||||
|
||||
@ViewModelScoped
|
||||
class FilterCoordinator @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val searchRepository: MangaSearchRepository,
|
||||
lifecycle: ViewModelLifecycle,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val searchRepository: MangaSearchRepository,
|
||||
private val savedFiltersRepository: SavedFiltersRepository,
|
||||
lifecycle: ViewModelLifecycle,
|
||||
) {
|
||||
|
||||
private val coroutineScope = lifecycle.lifecycleScope + Dispatchers.Default
|
||||
private val repository = mangaRepositoryFactory.create(MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE]))
|
||||
private val sourceLocale = (repository.source as? MangaParserSource)?.locale
|
||||
private val coroutineScope = lifecycle.lifecycleScope + Dispatchers.Default
|
||||
private val repository = mangaRepositoryFactory.create(MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE]))
|
||||
private val sourceLocale = (repository.source as? MangaParserSource)?.locale
|
||||
|
||||
private val currentListFilter = MutableStateFlow(MangaListFilter.EMPTY)
|
||||
private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder)
|
||||
private val currentListFilter = MutableStateFlow(MangaListFilter.EMPTY)
|
||||
private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder)
|
||||
|
||||
private val availableSortOrders = repository.sortOrders
|
||||
private val filterOptions = suspendLazy { repository.getFilterOptions() }
|
||||
val capabilities = repository.filterCapabilities
|
||||
private val availableSortOrders = repository.sortOrders
|
||||
private val filterOptions = suspendLazy { repository.getFilterOptions() }
|
||||
|
||||
val mangaSource: MangaSource
|
||||
get() = repository.source
|
||||
val capabilities = repository.filterCapabilities
|
||||
|
||||
val isFilterApplied: Boolean
|
||||
get() = currentListFilter.value.isNotEmpty()
|
||||
val mangaSource: MangaSource
|
||||
get() = repository.source
|
||||
|
||||
val query: StateFlow<String?> = currentListFilter.map { it.query }
|
||||
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
|
||||
val isFilterApplied: Boolean
|
||||
get() = currentListFilter.value.isNotEmpty()
|
||||
|
||||
val sortOrder: StateFlow<FilterProperty<SortOrder>> = currentSortOrder.map { selected ->
|
||||
FilterProperty(
|
||||
availableItems = availableSortOrders.sortedByOrdinal(),
|
||||
selectedItem = selected,
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
val query: StateFlow<String?> = currentListFilter.map { it.query }
|
||||
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
|
||||
|
||||
val tags: StateFlow<FilterProperty<MangaTag>> = combine(
|
||||
getTopTags(TAGS_LIMIT),
|
||||
currentListFilter.distinctUntilChangedBy { it.tags },
|
||||
) { available, selected ->
|
||||
available.fold(
|
||||
onSuccess = {
|
||||
FilterProperty(
|
||||
availableItems = it.addFirstDistinct(selected.tags),
|
||||
selectedItems = selected.tags,
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
FilterProperty.error(it)
|
||||
},
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
val sortOrder: StateFlow<FilterProperty<SortOrder>> = currentSortOrder.map { selected ->
|
||||
FilterProperty(
|
||||
availableItems = availableSortOrders.sortedByOrdinal(),
|
||||
selectedItem = selected,
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
|
||||
val tagsExcluded: StateFlow<FilterProperty<MangaTag>> = if (capabilities.isTagsExclusionSupported) {
|
||||
combine(
|
||||
getBottomTags(TAGS_LIMIT),
|
||||
currentListFilter.distinctUntilChangedBy { it.tagsExclude },
|
||||
) { available, selected ->
|
||||
available.fold(
|
||||
onSuccess = {
|
||||
FilterProperty(
|
||||
availableItems = it.addFirstDistinct(selected.tagsExclude),
|
||||
selectedItems = selected.tagsExclude,
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
FilterProperty.error(it)
|
||||
},
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
} else {
|
||||
MutableStateFlow(FilterProperty.EMPTY)
|
||||
}
|
||||
val tags: StateFlow<FilterProperty<MangaTag>> = combine(
|
||||
getTopTags(TAGS_LIMIT),
|
||||
currentListFilter.distinctUntilChangedBy { it.tags },
|
||||
) { available, selected ->
|
||||
available.fold(
|
||||
onSuccess = {
|
||||
FilterProperty(
|
||||
availableItems = it.addFirstDistinct(selected.tags),
|
||||
selectedItems = selected.tags,
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
FilterProperty.error(it)
|
||||
},
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
|
||||
val states: StateFlow<FilterProperty<MangaState>> = combine(
|
||||
filterOptions.asFlow(),
|
||||
currentListFilter.distinctUntilChangedBy { it.states },
|
||||
) { available, selected ->
|
||||
available.fold(
|
||||
onSuccess = {
|
||||
FilterProperty(
|
||||
availableItems = it.availableStates.sortedByOrdinal(),
|
||||
selectedItems = selected.states,
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
FilterProperty.error(it)
|
||||
},
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
val tagsExcluded: StateFlow<FilterProperty<MangaTag>> = if (capabilities.isTagsExclusionSupported) {
|
||||
combine(
|
||||
getBottomTags(TAGS_LIMIT),
|
||||
currentListFilter.distinctUntilChangedBy { it.tagsExclude },
|
||||
) { available, selected ->
|
||||
available.fold(
|
||||
onSuccess = {
|
||||
FilterProperty(
|
||||
availableItems = it.addFirstDistinct(selected.tagsExclude),
|
||||
selectedItems = selected.tagsExclude,
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
FilterProperty.error(it)
|
||||
},
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
} else {
|
||||
MutableStateFlow(FilterProperty.EMPTY)
|
||||
}
|
||||
|
||||
val contentRating: StateFlow<FilterProperty<ContentRating>> = combine(
|
||||
filterOptions.asFlow(),
|
||||
currentListFilter.distinctUntilChangedBy { it.contentRating },
|
||||
) { available, selected ->
|
||||
available.fold(
|
||||
onSuccess = {
|
||||
FilterProperty(
|
||||
availableItems = it.availableContentRating.sortedByOrdinal(),
|
||||
selectedItems = selected.contentRating,
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
FilterProperty.error(it)
|
||||
},
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
val authors: StateFlow<FilterProperty<String>> = if (capabilities.isAuthorSearchSupported) {
|
||||
combine(
|
||||
flow { emit(searchRepository.getAuthors(repository.source, TAGS_LIMIT)) },
|
||||
currentListFilter.distinctUntilChangedBy { it.author },
|
||||
) { available, selected ->
|
||||
FilterProperty(
|
||||
availableItems = available,
|
||||
selectedItems = setOfNotNull(selected.author),
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
} else {
|
||||
MutableStateFlow(FilterProperty.EMPTY)
|
||||
}
|
||||
|
||||
val contentTypes: StateFlow<FilterProperty<ContentType>> = combine(
|
||||
filterOptions.asFlow(),
|
||||
currentListFilter.distinctUntilChangedBy { it.types },
|
||||
) { available, selected ->
|
||||
available.fold(
|
||||
onSuccess = {
|
||||
FilterProperty(
|
||||
availableItems = it.availableContentTypes.sortedByOrdinal(),
|
||||
selectedItems = selected.types,
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
FilterProperty.error(it)
|
||||
},
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
val states: StateFlow<FilterProperty<MangaState>> = combine(
|
||||
filterOptions.asFlow(),
|
||||
currentListFilter.distinctUntilChangedBy { it.states },
|
||||
) { available, selected ->
|
||||
available.fold(
|
||||
onSuccess = {
|
||||
FilterProperty(
|
||||
availableItems = it.availableStates.sortedByOrdinal(),
|
||||
selectedItems = selected.states,
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
FilterProperty.error(it)
|
||||
},
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
|
||||
val demographics: StateFlow<FilterProperty<Demographic>> = combine(
|
||||
filterOptions.asFlow(),
|
||||
currentListFilter.distinctUntilChangedBy { it.demographics },
|
||||
) { available, selected ->
|
||||
available.fold(
|
||||
onSuccess = {
|
||||
FilterProperty(
|
||||
availableItems = it.availableDemographics.sortedByOrdinal(),
|
||||
selectedItems = selected.demographics,
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
FilterProperty.error(it)
|
||||
},
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
val contentRating: StateFlow<FilterProperty<ContentRating>> = combine(
|
||||
filterOptions.asFlow(),
|
||||
currentListFilter.distinctUntilChangedBy { it.contentRating },
|
||||
) { available, selected ->
|
||||
available.fold(
|
||||
onSuccess = {
|
||||
FilterProperty(
|
||||
availableItems = it.availableContentRating.sortedByOrdinal(),
|
||||
selectedItems = selected.contentRating,
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
FilterProperty.error(it)
|
||||
},
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
|
||||
val locale: StateFlow<FilterProperty<Locale?>> = combine(
|
||||
filterOptions.asFlow(),
|
||||
currentListFilter.distinctUntilChangedBy { it.locale },
|
||||
) { available, selected ->
|
||||
available.fold(
|
||||
onSuccess = {
|
||||
FilterProperty(
|
||||
availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null),
|
||||
selectedItems = setOfNotNull(selected.locale),
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
FilterProperty.error(it)
|
||||
},
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
val contentTypes: StateFlow<FilterProperty<ContentType>> = combine(
|
||||
filterOptions.asFlow(),
|
||||
currentListFilter.distinctUntilChangedBy { it.types },
|
||||
) { available, selected ->
|
||||
available.fold(
|
||||
onSuccess = {
|
||||
FilterProperty(
|
||||
availableItems = it.availableContentTypes.sortedByOrdinal(),
|
||||
selectedItems = selected.types,
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
FilterProperty.error(it)
|
||||
},
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
|
||||
val originalLocale: StateFlow<FilterProperty<Locale?>> = if (capabilities.isOriginalLocaleSupported) {
|
||||
combine(
|
||||
filterOptions.asFlow(),
|
||||
currentListFilter.distinctUntilChangedBy { it.originalLocale },
|
||||
) { available, selected ->
|
||||
available.fold(
|
||||
onSuccess = {
|
||||
FilterProperty(
|
||||
availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null),
|
||||
selectedItems = setOfNotNull(selected.originalLocale),
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
FilterProperty.error(it)
|
||||
},
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
} else {
|
||||
MutableStateFlow(FilterProperty.EMPTY)
|
||||
}
|
||||
val demographics: StateFlow<FilterProperty<Demographic>> = combine(
|
||||
filterOptions.asFlow(),
|
||||
currentListFilter.distinctUntilChangedBy { it.demographics },
|
||||
) { available, selected ->
|
||||
available.fold(
|
||||
onSuccess = {
|
||||
FilterProperty(
|
||||
availableItems = it.availableDemographics.sortedByOrdinal(),
|
||||
selectedItems = selected.demographics,
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
FilterProperty.error(it)
|
||||
},
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
|
||||
val year: StateFlow<FilterProperty<Int>> = if (capabilities.isYearSupported) {
|
||||
currentListFilter.distinctUntilChangedBy { it.year }.map { selected ->
|
||||
FilterProperty(
|
||||
availableItems = listOf(YEAR_MIN, MAX_YEAR),
|
||||
selectedItems = setOf(selected.year),
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
} else {
|
||||
MutableStateFlow(FilterProperty.EMPTY)
|
||||
}
|
||||
val locale: StateFlow<FilterProperty<Locale?>> = combine(
|
||||
filterOptions.asFlow(),
|
||||
currentListFilter.distinctUntilChangedBy { it.locale },
|
||||
) { available, selected ->
|
||||
available.fold(
|
||||
onSuccess = {
|
||||
FilterProperty(
|
||||
availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null),
|
||||
selectedItems = setOfNotNull(selected.locale),
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
FilterProperty.error(it)
|
||||
},
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
|
||||
val yearRange: StateFlow<FilterProperty<Int>> = if (capabilities.isYearRangeSupported) {
|
||||
currentListFilter.distinctUntilChanged { old, new ->
|
||||
old.yearTo == new.yearTo && old.yearFrom == new.yearFrom
|
||||
}.map { selected ->
|
||||
FilterProperty(
|
||||
availableItems = listOf(YEAR_MIN, MAX_YEAR),
|
||||
selectedItems = setOf(selected.yearFrom.ifZero { YEAR_MIN }, selected.yearTo.ifZero { MAX_YEAR }),
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
} else {
|
||||
MutableStateFlow(FilterProperty.EMPTY)
|
||||
}
|
||||
val originalLocale: StateFlow<FilterProperty<Locale?>> = if (capabilities.isOriginalLocaleSupported) {
|
||||
combine(
|
||||
filterOptions.asFlow(),
|
||||
currentListFilter.distinctUntilChangedBy { it.originalLocale },
|
||||
) { available, selected ->
|
||||
available.fold(
|
||||
onSuccess = {
|
||||
FilterProperty(
|
||||
availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null),
|
||||
selectedItems = setOfNotNull(selected.originalLocale),
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
FilterProperty.error(it)
|
||||
},
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
} else {
|
||||
MutableStateFlow(FilterProperty.EMPTY)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
currentListFilter.value = MangaListFilter.EMPTY
|
||||
}
|
||||
val year: StateFlow<FilterProperty<Int>> = if (capabilities.isYearSupported) {
|
||||
currentListFilter.distinctUntilChangedBy { it.year }.map { selected ->
|
||||
FilterProperty(
|
||||
availableItems = listOf(YEAR_MIN, MAX_YEAR),
|
||||
selectedItems = setOf(selected.year),
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
} else {
|
||||
MutableStateFlow(FilterProperty.EMPTY)
|
||||
}
|
||||
|
||||
fun snapshot() = Snapshot(
|
||||
sortOrder = currentSortOrder.value,
|
||||
listFilter = currentListFilter.value,
|
||||
)
|
||||
val yearRange: StateFlow<FilterProperty<Int>> = if (capabilities.isYearRangeSupported) {
|
||||
currentListFilter.distinctUntilChanged { old, new ->
|
||||
old.yearTo == new.yearTo && old.yearFrom == new.yearFrom
|
||||
}.map { selected ->
|
||||
FilterProperty(
|
||||
availableItems = listOf(YEAR_MIN, MAX_YEAR),
|
||||
selectedItems = setOf(selected.yearFrom.ifZero { YEAR_MIN }, selected.yearTo.ifZero { MAX_YEAR }),
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
} else {
|
||||
MutableStateFlow(FilterProperty.EMPTY)
|
||||
}
|
||||
|
||||
fun observe(): Flow<Snapshot> = combine(currentSortOrder, currentListFilter, ::Snapshot)
|
||||
val savedFilters: StateFlow<FilterProperty<PersistableFilter>> = combine(
|
||||
savedFiltersRepository.observeAll(repository.source),
|
||||
currentListFilter,
|
||||
) { available, applied ->
|
||||
FilterProperty(
|
||||
availableItems = available,
|
||||
selectedItems = setOfNotNull(available.find { it.filter == applied }),
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.EMPTY)
|
||||
|
||||
fun setSortOrder(newSortOrder: SortOrder) {
|
||||
currentSortOrder.value = newSortOrder
|
||||
repository.defaultSortOrder = newSortOrder
|
||||
}
|
||||
fun reset() {
|
||||
currentListFilter.value = MangaListFilter.EMPTY
|
||||
}
|
||||
|
||||
fun set(value: MangaListFilter) {
|
||||
currentListFilter.value = value
|
||||
}
|
||||
fun snapshot() = Snapshot(
|
||||
sortOrder = currentSortOrder.value,
|
||||
listFilter = currentListFilter.value,
|
||||
)
|
||||
|
||||
fun setAdjusted(value: MangaListFilter) {
|
||||
var newFilter = value
|
||||
if (!newFilter.author.isNullOrEmpty() && !capabilities.isAuthorSearchSupported) {
|
||||
newFilter = newFilter.copy(
|
||||
query = newFilter.author,
|
||||
author = null,
|
||||
)
|
||||
}
|
||||
if (!capabilities.isSearchSupported && !newFilter.query.isNullOrEmpty()) {
|
||||
newFilter = newFilter.copy(
|
||||
query = null,
|
||||
)
|
||||
}
|
||||
if (!newFilter.query.isNullOrEmpty() && !newFilter.hasNonSearchOptions() && !capabilities.isSearchWithFiltersSupported) {
|
||||
newFilter = MangaListFilter(query = newFilter.query)
|
||||
}
|
||||
set(newFilter)
|
||||
}
|
||||
fun observe(): Flow<Snapshot> = combine(currentSortOrder, currentListFilter, ::Snapshot)
|
||||
|
||||
fun setQuery(value: String?) {
|
||||
val newQuery = value?.trim()?.nullIfEmpty()
|
||||
currentListFilter.update { oldValue ->
|
||||
if (capabilities.isSearchWithFiltersSupported || newQuery == null) {
|
||||
oldValue.copy(query = newQuery)
|
||||
} else {
|
||||
MangaListFilter(query = newQuery)
|
||||
}
|
||||
}
|
||||
}
|
||||
fun setSortOrder(newSortOrder: SortOrder) {
|
||||
currentSortOrder.value = newSortOrder
|
||||
repository.defaultSortOrder = newSortOrder
|
||||
}
|
||||
|
||||
fun setLocale(value: Locale?) {
|
||||
currentListFilter.update { oldValue ->
|
||||
oldValue.copy(
|
||||
locale = value,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
fun set(value: MangaListFilter) {
|
||||
currentListFilter.value = value
|
||||
}
|
||||
|
||||
fun setAuthor(value: String?) {
|
||||
currentListFilter.update { oldValue ->
|
||||
oldValue.copy(
|
||||
author = value,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
fun setAdjusted(value: MangaListFilter) {
|
||||
var newFilter = value
|
||||
if (!newFilter.author.isNullOrEmpty() && !capabilities.isAuthorSearchSupported) {
|
||||
newFilter = newFilter.copy(
|
||||
query = newFilter.author,
|
||||
author = null,
|
||||
)
|
||||
}
|
||||
if (!newFilter.query.isNullOrEmpty() && !newFilter.hasNonSearchOptions() && !capabilities.isSearchWithFiltersSupported) {
|
||||
newFilter = MangaListFilter(query = newFilter.query)
|
||||
}
|
||||
set(newFilter)
|
||||
}
|
||||
|
||||
fun setOriginalLocale(value: Locale?) {
|
||||
currentListFilter.update { oldValue ->
|
||||
oldValue.copy(
|
||||
originalLocale = value,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
fun saveCurrentFilter(name: String) = coroutineScope.launch {
|
||||
savedFiltersRepository.save(repository.source, name, currentListFilter.value)
|
||||
}
|
||||
|
||||
fun setYear(value: Int) {
|
||||
currentListFilter.update { oldValue ->
|
||||
oldValue.copy(
|
||||
year = value,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
fun renameSavedFilter(id: Int, newName: String) = coroutineScope.launch {
|
||||
savedFiltersRepository.rename(repository.source, id, newName)
|
||||
}
|
||||
|
||||
fun setYearRange(valueFrom: Int, valueTo: Int) {
|
||||
currentListFilter.update { oldValue ->
|
||||
oldValue.copy(
|
||||
yearFrom = valueFrom,
|
||||
yearTo = valueTo,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
fun deleteSavedFilter(id: Int) = coroutineScope.launch {
|
||||
savedFiltersRepository.delete(repository.source, id)
|
||||
}
|
||||
|
||||
fun toggleState(value: MangaState, isSelected: Boolean) {
|
||||
currentListFilter.update { oldValue ->
|
||||
oldValue.copy(
|
||||
states = if (isSelected) oldValue.states + value else oldValue.states - value,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
fun setQuery(value: String?) {
|
||||
val newQuery = value?.trim()?.nullIfEmpty()
|
||||
currentListFilter.update { oldValue ->
|
||||
if (capabilities.isSearchWithFiltersSupported || newQuery == null) {
|
||||
oldValue.copy(query = newQuery)
|
||||
} else {
|
||||
MangaListFilter(query = newQuery)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleContentRating(value: ContentRating, isSelected: Boolean) {
|
||||
currentListFilter.update { oldValue ->
|
||||
oldValue.copy(
|
||||
contentRating = if (isSelected) oldValue.contentRating + value else oldValue.contentRating - value,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
fun setLocale(value: Locale?) {
|
||||
currentListFilter.update { oldValue ->
|
||||
oldValue.copy(
|
||||
locale = value,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleDemographic(value: Demographic, isSelected: Boolean) {
|
||||
currentListFilter.update { oldValue ->
|
||||
oldValue.copy(
|
||||
demographics = if (isSelected) oldValue.demographics + value else oldValue.demographics - value,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
fun setAuthor(value: String?) {
|
||||
currentListFilter.update { oldValue ->
|
||||
oldValue.copy(
|
||||
author = value,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleContentType(value: ContentType, isSelected: Boolean) {
|
||||
currentListFilter.update { oldValue ->
|
||||
oldValue.copy(
|
||||
types = if (isSelected) oldValue.types + value else oldValue.types - value,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
fun setOriginalLocale(value: Locale?) {
|
||||
currentListFilter.update { oldValue ->
|
||||
oldValue.copy(
|
||||
originalLocale = value,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleTag(value: MangaTag, isSelected: Boolean) {
|
||||
currentListFilter.update { oldValue ->
|
||||
val newTags = if (capabilities.isMultipleTagsSupported) {
|
||||
if (isSelected) oldValue.tags + value else oldValue.tags - value
|
||||
} else {
|
||||
if (isSelected) setOf(value) else emptySet()
|
||||
}
|
||||
oldValue.copy(
|
||||
tags = newTags,
|
||||
tagsExclude = oldValue.tagsExclude - newTags,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
fun setYear(value: Int) {
|
||||
currentListFilter.update { oldValue ->
|
||||
oldValue.copy(
|
||||
year = value,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleTagExclude(value: MangaTag, isSelected: Boolean) {
|
||||
currentListFilter.update { oldValue ->
|
||||
val newTagsExclude = if (capabilities.isMultipleTagsSupported) {
|
||||
if (isSelected) oldValue.tagsExclude + value else oldValue.tagsExclude - value
|
||||
} else {
|
||||
if (isSelected) setOf(value) else emptySet()
|
||||
}
|
||||
oldValue.copy(
|
||||
tags = oldValue.tags - newTagsExclude,
|
||||
tagsExclude = newTagsExclude,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
fun setYearRange(valueFrom: Int, valueTo: Int) {
|
||||
currentListFilter.update { oldValue ->
|
||||
oldValue.copy(
|
||||
yearFrom = valueFrom,
|
||||
yearTo = valueTo,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAllTags(): Flow<Result<List<MangaTag>>> = filterOptions.asFlow().map {
|
||||
it.map { x -> x.availableTags.sortedWithSafe(TagTitleComparator(sourceLocale)) }
|
||||
}
|
||||
fun toggleState(value: MangaState, isSelected: Boolean) {
|
||||
currentListFilter.update { oldValue ->
|
||||
oldValue.copy(
|
||||
states = if (isSelected) oldValue.states + value else oldValue.states - value,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MangaListFilter.takeQueryIfSupported() = when {
|
||||
capabilities.isSearchWithFiltersSupported -> query
|
||||
query.isNullOrEmpty() -> query
|
||||
hasNonSearchOptions() -> null
|
||||
else -> query
|
||||
}
|
||||
fun toggleContentRating(value: ContentRating, isSelected: Boolean) {
|
||||
currentListFilter.update { oldValue ->
|
||||
oldValue.copy(
|
||||
contentRating = if (isSelected) oldValue.contentRating + value else oldValue.contentRating - value,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTopTags(limit: Int): Flow<Result<List<MangaTag>>> = combine(
|
||||
flow { emit(searchRepository.getTopTags(repository.source, limit)) },
|
||||
filterOptions.asFlow(),
|
||||
) { suggested, options ->
|
||||
val all = options.getOrNull()?.availableTags.orEmpty()
|
||||
val result = ArrayList<MangaTag>(limit)
|
||||
result.addAll(suggested.take(limit))
|
||||
if (result.size < limit) {
|
||||
result.addAll(all.shuffled().take(limit - result.size))
|
||||
}
|
||||
if (result.isNotEmpty()) {
|
||||
Result.success(result)
|
||||
} else {
|
||||
options.map { result }
|
||||
}
|
||||
}.catch {
|
||||
emit(Result.failure(it))
|
||||
}
|
||||
fun toggleDemographic(value: Demographic, isSelected: Boolean) {
|
||||
currentListFilter.update { oldValue ->
|
||||
oldValue.copy(
|
||||
demographics = if (isSelected) oldValue.demographics + value else oldValue.demographics - value,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBottomTags(limit: Int): Flow<Result<List<MangaTag>>> = combine(
|
||||
flow { emit(searchRepository.getRareTags(repository.source, limit)) },
|
||||
filterOptions.asFlow(),
|
||||
) { suggested, options ->
|
||||
val all = options.getOrNull()?.availableTags.orEmpty()
|
||||
val result = ArrayList<MangaTag>(limit)
|
||||
result.addAll(suggested.take(limit))
|
||||
if (result.size < limit) {
|
||||
result.addAll(all.shuffled().take(limit - result.size))
|
||||
}
|
||||
if (result.isNotEmpty()) {
|
||||
Result.success(result)
|
||||
} else {
|
||||
options.map { result }
|
||||
}
|
||||
}.catch {
|
||||
emit(Result.failure(it))
|
||||
}
|
||||
fun toggleContentType(value: ContentType, isSelected: Boolean) {
|
||||
currentListFilter.update { oldValue ->
|
||||
oldValue.copy(
|
||||
types = if (isSelected) oldValue.types + value else oldValue.types - value,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> List<T>.addFirstDistinct(other: Collection<T>): List<T> {
|
||||
val result = ArrayDeque<T>(this.size + other.size)
|
||||
result.addAll(this)
|
||||
for (item in other) {
|
||||
if (item !in result) {
|
||||
result.addFirst(item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
fun toggleTag(value: MangaTag, isSelected: Boolean) {
|
||||
currentListFilter.update { oldValue ->
|
||||
val newTags = if (capabilities.isMultipleTagsSupported) {
|
||||
if (isSelected) oldValue.tags + value else oldValue.tags - value
|
||||
} else {
|
||||
if (isSelected) setOf(value) else emptySet()
|
||||
}
|
||||
oldValue.copy(
|
||||
tags = newTags,
|
||||
tagsExclude = oldValue.tagsExclude - newTags,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> List<T>.addFirstDistinct(item: T): List<T> {
|
||||
val result = ArrayDeque<T>(this.size + 1)
|
||||
result.addAll(this)
|
||||
if (item !in result) {
|
||||
result.addFirst(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
fun toggleTagExclude(value: MangaTag, isSelected: Boolean) {
|
||||
currentListFilter.update { oldValue ->
|
||||
val newTagsExclude = if (capabilities.isMultipleTagsSupported) {
|
||||
if (isSelected) oldValue.tagsExclude + value else oldValue.tagsExclude - value
|
||||
} else {
|
||||
if (isSelected) setOf(value) else emptySet()
|
||||
}
|
||||
oldValue.copy(
|
||||
tags = oldValue.tags - newTagsExclude,
|
||||
tagsExclude = newTagsExclude,
|
||||
query = oldValue.takeQueryIfSupported(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class Snapshot(
|
||||
val sortOrder: SortOrder,
|
||||
val listFilter: MangaListFilter,
|
||||
)
|
||||
fun getAllTags(): Flow<Result<List<MangaTag>>> = filterOptions.asFlow().map {
|
||||
it.map { x -> x.availableTags.sortedWithSafe(TagTitleComparator(sourceLocale)) }
|
||||
}
|
||||
|
||||
interface Owner {
|
||||
private fun MangaListFilter.takeQueryIfSupported() = when {
|
||||
capabilities.isSearchWithFiltersSupported -> query
|
||||
query.isNullOrEmpty() -> query
|
||||
hasNonSearchOptions() -> null
|
||||
else -> query
|
||||
}
|
||||
|
||||
val filterCoordinator: FilterCoordinator
|
||||
}
|
||||
private fun getTopTags(limit: Int): Flow<Result<List<MangaTag>>> = combine(
|
||||
flow { emit(searchRepository.getTopTags(repository.source, limit)) },
|
||||
filterOptions.asFlow(),
|
||||
) { suggested, options ->
|
||||
val all = options.getOrNull()?.availableTags.orEmpty()
|
||||
val result = ArrayList<MangaTag>(limit)
|
||||
result.addAll(suggested.take(limit))
|
||||
if (result.size < limit) {
|
||||
result.addAll(all.shuffled().take(limit - result.size))
|
||||
}
|
||||
if (result.isNotEmpty()) {
|
||||
Result.success(result)
|
||||
} else {
|
||||
options.map { result }
|
||||
}
|
||||
}.catch {
|
||||
emit(Result.failure(it))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun getBottomTags(limit: Int): Flow<Result<List<MangaTag>>> = combine(
|
||||
flow { emit(searchRepository.getRareTags(repository.source, limit)) },
|
||||
filterOptions.asFlow(),
|
||||
) { suggested, options ->
|
||||
val all = options.getOrNull()?.availableTags.orEmpty()
|
||||
val result = ArrayList<MangaTag>(limit)
|
||||
result.addAll(suggested.take(limit))
|
||||
if (result.size < limit) {
|
||||
result.addAll(all.shuffled().take(limit - result.size))
|
||||
}
|
||||
if (result.isNotEmpty()) {
|
||||
Result.success(result)
|
||||
} else {
|
||||
options.map { result }
|
||||
}
|
||||
}.catch {
|
||||
emit(Result.failure(it))
|
||||
}
|
||||
|
||||
private const val TAGS_LIMIT = 12
|
||||
private val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1
|
||||
private fun <T> List<T>.addFirstDistinct(other: Collection<T>): List<T> {
|
||||
val result = ArrayDeque<T>(this.size + other.size)
|
||||
result.addAll(this)
|
||||
for (item in other) {
|
||||
if (item !in result) {
|
||||
result.addFirst(item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun find(fragment: Fragment): FilterCoordinator? {
|
||||
(fragment.activity as? Owner)?.let {
|
||||
return it.filterCoordinator
|
||||
}
|
||||
var f = fragment
|
||||
while (true) {
|
||||
(f as? Owner)?.let {
|
||||
return it.filterCoordinator
|
||||
}
|
||||
f = f.parentFragment ?: break
|
||||
}
|
||||
return null
|
||||
}
|
||||
private fun <T> List<T>.addFirstDistinct(item: T): List<T> {
|
||||
val result = ArrayDeque<T>(this.size + 1)
|
||||
result.addAll(this)
|
||||
if (item !in result) {
|
||||
result.addFirst(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun require(fragment: Fragment): FilterCoordinator {
|
||||
return find(fragment) ?: throw IllegalStateException("FilterCoordinator cannot be found")
|
||||
}
|
||||
}
|
||||
data class Snapshot(
|
||||
val sortOrder: SortOrder,
|
||||
val listFilter: MangaListFilter,
|
||||
)
|
||||
|
||||
interface Owner {
|
||||
|
||||
val filterCoordinator: FilterCoordinator
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAGS_LIMIT = 12
|
||||
private val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1
|
||||
|
||||
fun find(fragment: Fragment): FilterCoordinator? {
|
||||
(fragment.activity as? Owner)?.let {
|
||||
return it.filterCoordinator
|
||||
}
|
||||
var f = fragment
|
||||
while (true) {
|
||||
(f as? Owner)?.let {
|
||||
return it.filterCoordinator
|
||||
}
|
||||
f = f.parentFragment ?: break
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun require(fragment: Fragment): FilterCoordinator {
|
||||
return find(fragment) ?: throw IllegalStateException("FilterCoordinator cannot be found")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding
|
||||
import org.koitharu.kotatsu.filter.data.PersistableFilter
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
@@ -28,69 +29,75 @@ import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsView.OnChipClickListener,
|
||||
ChipsView.OnChipCloseClickListener {
|
||||
ChipsView.OnChipCloseClickListener {
|
||||
|
||||
@Inject
|
||||
lateinit var filterHeaderProducer: FilterHeaderProducer
|
||||
@Inject
|
||||
lateinit var filterHeaderProducer: FilterHeaderProducer
|
||||
|
||||
private val filter: FilterCoordinator
|
||||
get() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator
|
||||
private val filter: FilterCoordinator
|
||||
get() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding {
|
||||
return FragmentFilterHeaderBinding.inflate(inflater, container, false)
|
||||
}
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding {
|
||||
return FragmentFilterHeaderBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
binding.chipsTags.onChipClickListener = this
|
||||
binding.chipsTags.onChipCloseClickListener = this
|
||||
filterHeaderProducer.observeHeader(filter)
|
||||
.flowOn(Dispatchers.Default)
|
||||
.observe(viewLifecycleOwner, ::onDataChanged)
|
||||
}
|
||||
override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
binding.chipsTags.onChipClickListener = this
|
||||
binding.chipsTags.onChipCloseClickListener = this
|
||||
filterHeaderProducer.observeHeader(filter)
|
||||
.flowOn(Dispatchers.Default)
|
||||
.observe(viewLifecycleOwner, ::onDataChanged)
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets
|
||||
|
||||
override fun onChipClick(chip: Chip, data: Any?) {
|
||||
when (data) {
|
||||
is MangaTag -> filter.toggleTag(data, !chip.isChecked)
|
||||
is String -> Unit
|
||||
null -> router.showTagsCatalogSheet(excludeMode = false)
|
||||
}
|
||||
}
|
||||
override fun onChipClick(chip: Chip, data: Any?) {
|
||||
when (data) {
|
||||
is MangaTag -> filter.toggleTag(data, !chip.isChecked)
|
||||
is PersistableFilter -> if (chip.isChecked) {
|
||||
filter.reset()
|
||||
} else {
|
||||
filter.setAdjusted(data.filter)
|
||||
}
|
||||
|
||||
override fun onChipCloseClick(chip: Chip, data: Any?) {
|
||||
when (data) {
|
||||
is String -> if (data == filter.snapshot().listFilter.author) {
|
||||
filter.setAuthor(null)
|
||||
} else {
|
||||
filter.setQuery(null)
|
||||
}
|
||||
is String -> Unit
|
||||
null -> router.showTagsCatalogSheet(excludeMode = false)
|
||||
}
|
||||
}
|
||||
|
||||
is ContentRating -> filter.toggleContentRating(data, false)
|
||||
is Demographic -> filter.toggleDemographic(data, false)
|
||||
is ContentType -> filter.toggleContentType(data, false)
|
||||
is MangaState -> filter.toggleState(data, false)
|
||||
is Locale -> filter.setLocale(null)
|
||||
is Int -> filter.setYear(YEAR_UNKNOWN)
|
||||
is IntRange -> filter.setYearRange(YEAR_UNKNOWN, YEAR_UNKNOWN)
|
||||
}
|
||||
}
|
||||
override fun onChipCloseClick(chip: Chip, data: Any?) {
|
||||
when (data) {
|
||||
is String -> if (data == filter.snapshot().listFilter.author) {
|
||||
filter.setAuthor(null)
|
||||
} else {
|
||||
filter.setQuery(null)
|
||||
}
|
||||
|
||||
private fun onDataChanged(header: FilterHeaderModel) {
|
||||
val binding = viewBinding ?: return
|
||||
val chips = header.chips
|
||||
if (chips.isEmpty()) {
|
||||
binding.chipsTags.setChips(emptyList())
|
||||
binding.root.isVisible = false
|
||||
return
|
||||
}
|
||||
binding.chipsTags.setChips(header.chips)
|
||||
binding.root.isVisible = true
|
||||
if (binding.root.context.isAnimationsEnabled) {
|
||||
binding.scrollView.smoothScrollTo(0, 0)
|
||||
} else {
|
||||
binding.scrollView.scrollTo(0, 0)
|
||||
}
|
||||
}
|
||||
is ContentRating -> filter.toggleContentRating(data, false)
|
||||
is Demographic -> filter.toggleDemographic(data, false)
|
||||
is ContentType -> filter.toggleContentType(data, false)
|
||||
is MangaState -> filter.toggleState(data, false)
|
||||
is Locale -> filter.setLocale(null)
|
||||
is Int -> filter.setYear(YEAR_UNKNOWN)
|
||||
is IntRange -> filter.setYearRange(YEAR_UNKNOWN, YEAR_UNKNOWN)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDataChanged(header: FilterHeaderModel) {
|
||||
val binding = viewBinding ?: return
|
||||
val chips = header.chips
|
||||
if (chips.isEmpty()) {
|
||||
binding.chipsTags.setChips(emptyList())
|
||||
binding.root.isVisible = false
|
||||
return
|
||||
}
|
||||
binding.chipsTags.setChips(header.chips)
|
||||
binding.root.isVisible = true
|
||||
if (binding.root.context.isAnimationsEnabled) {
|
||||
binding.scrollView.smoothScrollTo(0, 0)
|
||||
} else {
|
||||
binding.scrollView.scrollTo(0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.combine
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.titleResId
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.filter.data.PersistableFilter
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
@@ -17,143 +18,162 @@ import javax.inject.Inject
|
||||
import androidx.appcompat.R as appcompatR
|
||||
|
||||
class FilterHeaderProducer @Inject constructor(
|
||||
private val searchRepository: MangaSearchRepository,
|
||||
private val searchRepository: MangaSearchRepository,
|
||||
) {
|
||||
|
||||
fun observeHeader(filterCoordinator: FilterCoordinator): Flow<FilterHeaderModel> {
|
||||
return combine(filterCoordinator.tags, filterCoordinator.observe()) { tags, snapshot ->
|
||||
val chipList = createChipsList(
|
||||
source = filterCoordinator.mangaSource,
|
||||
capabilities = filterCoordinator.capabilities,
|
||||
tagsProperty = tags,
|
||||
snapshot = snapshot.listFilter,
|
||||
limit = 12,
|
||||
)
|
||||
FilterHeaderModel(
|
||||
chips = chipList,
|
||||
sortOrder = snapshot.sortOrder,
|
||||
isFilterApplied = !snapshot.listFilter.isEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
fun observeHeader(filterCoordinator: FilterCoordinator): Flow<FilterHeaderModel> {
|
||||
return combine(
|
||||
filterCoordinator.savedFilters,
|
||||
filterCoordinator.tags,
|
||||
filterCoordinator.observe(),
|
||||
) { saved, tags, snapshot ->
|
||||
val chipList = createChipsList(
|
||||
source = filterCoordinator.mangaSource,
|
||||
capabilities = filterCoordinator.capabilities,
|
||||
savedFilters = saved,
|
||||
tagsProperty = tags,
|
||||
snapshot = snapshot.listFilter,
|
||||
limit = 12,
|
||||
)
|
||||
FilterHeaderModel(
|
||||
chips = chipList,
|
||||
sortOrder = snapshot.sortOrder,
|
||||
isFilterApplied = !snapshot.listFilter.isEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createChipsList(
|
||||
source: MangaSource,
|
||||
capabilities: MangaListFilterCapabilities,
|
||||
tagsProperty: FilterProperty<MangaTag>,
|
||||
snapshot: MangaListFilter,
|
||||
limit: Int,
|
||||
): List<ChipsView.ChipModel> {
|
||||
val result = ArrayDeque<ChipsView.ChipModel>(limit + 3)
|
||||
if (snapshot.query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) {
|
||||
val selectedTags = tagsProperty.selectedItems.toMutableSet()
|
||||
var tags = if (selectedTags.isEmpty()) {
|
||||
searchRepository.getTagsSuggestion("", limit, source)
|
||||
} else {
|
||||
searchRepository.getTagsSuggestion(selectedTags).take(limit)
|
||||
}
|
||||
if (tags.size < limit) {
|
||||
tags = tags + tagsProperty.availableItems.take(limit - tags.size)
|
||||
}
|
||||
if (tags.isEmpty() && selectedTags.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
for (tag in tags) {
|
||||
val model = ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
isChecked = selectedTags.remove(tag),
|
||||
data = tag,
|
||||
)
|
||||
if (model.isChecked) {
|
||||
result.addFirst(model)
|
||||
} else {
|
||||
result.addLast(model)
|
||||
}
|
||||
}
|
||||
for (tag in selectedTags) {
|
||||
val model = ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
isChecked = true,
|
||||
data = tag,
|
||||
)
|
||||
result.addFirst(model)
|
||||
}
|
||||
}
|
||||
snapshot.locale?.let {
|
||||
result.addFirst(
|
||||
ChipsView.ChipModel(
|
||||
title = it.getDisplayName(it).toTitleCase(it),
|
||||
icon = R.drawable.ic_language,
|
||||
isCloseable = true,
|
||||
data = it,
|
||||
),
|
||||
)
|
||||
}
|
||||
snapshot.types.forEach {
|
||||
result.addFirst(
|
||||
ChipsView.ChipModel(
|
||||
titleResId = it.titleResId,
|
||||
isCloseable = true,
|
||||
data = it,
|
||||
),
|
||||
)
|
||||
}
|
||||
snapshot.demographics.forEach {
|
||||
result.addFirst(
|
||||
ChipsView.ChipModel(
|
||||
titleResId = it.titleResId,
|
||||
isCloseable = true,
|
||||
data = it,
|
||||
),
|
||||
)
|
||||
}
|
||||
snapshot.contentRating.forEach {
|
||||
result.addFirst(
|
||||
ChipsView.ChipModel(
|
||||
titleResId = it.titleResId,
|
||||
isCloseable = true,
|
||||
data = it,
|
||||
),
|
||||
)
|
||||
}
|
||||
snapshot.states.forEach {
|
||||
result.addFirst(
|
||||
ChipsView.ChipModel(
|
||||
titleResId = it.titleResId,
|
||||
isCloseable = true,
|
||||
data = it,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (!snapshot.query.isNullOrEmpty()) {
|
||||
result.addFirst(
|
||||
ChipsView.ChipModel(
|
||||
title = snapshot.query,
|
||||
icon = appcompatR.drawable.abc_ic_search_api_material,
|
||||
isCloseable = true,
|
||||
data = snapshot.query,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (!snapshot.author.isNullOrEmpty()) {
|
||||
result.addFirst(
|
||||
ChipsView.ChipModel(
|
||||
title = snapshot.author,
|
||||
icon = R.drawable.ic_user,
|
||||
isCloseable = true,
|
||||
data = snapshot.author,
|
||||
),
|
||||
)
|
||||
}
|
||||
val hasTags = result.any { it.data is MangaTag }
|
||||
if (hasTags) {
|
||||
result.addFirst(moreTagsChip())
|
||||
}
|
||||
return result
|
||||
}
|
||||
private suspend fun createChipsList(
|
||||
source: MangaSource,
|
||||
capabilities: MangaListFilterCapabilities,
|
||||
savedFilters: FilterProperty<PersistableFilter>,
|
||||
tagsProperty: FilterProperty<MangaTag>,
|
||||
snapshot: MangaListFilter,
|
||||
limit: Int,
|
||||
): List<ChipsView.ChipModel> {
|
||||
val result = ArrayDeque<ChipsView.ChipModel>(savedFilters.availableItems.size + limit + 3)
|
||||
if (snapshot.query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) {
|
||||
val selectedTags = tagsProperty.selectedItems.toMutableSet()
|
||||
var tags = if (selectedTags.isEmpty()) {
|
||||
searchRepository.getTagsSuggestion("", limit, source)
|
||||
} else {
|
||||
searchRepository.getTagsSuggestion(selectedTags).take(limit)
|
||||
}
|
||||
if (tags.size < limit) {
|
||||
tags = tags + tagsProperty.availableItems.take(limit - tags.size)
|
||||
}
|
||||
if (tags.isEmpty() && selectedTags.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
for (saved in savedFilters.availableItems) {
|
||||
val model = ChipsView.ChipModel(
|
||||
title = saved.name,
|
||||
isChecked = saved in savedFilters.selectedItems,
|
||||
data = saved,
|
||||
)
|
||||
if (model.isChecked) {
|
||||
selectedTags.removeAll(saved.filter.tags)
|
||||
result.addFirst(model)
|
||||
} else {
|
||||
result.addLast(model)
|
||||
}
|
||||
}
|
||||
for (tag in tags) {
|
||||
val model = ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
isChecked = selectedTags.remove(tag),
|
||||
data = tag,
|
||||
)
|
||||
if (model.isChecked) {
|
||||
result.addFirst(model)
|
||||
} else {
|
||||
result.addLast(model)
|
||||
}
|
||||
}
|
||||
for (tag in selectedTags) {
|
||||
val model = ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
isChecked = true,
|
||||
data = tag,
|
||||
)
|
||||
result.addFirst(model)
|
||||
}
|
||||
}
|
||||
snapshot.locale?.let {
|
||||
result.addFirst(
|
||||
ChipsView.ChipModel(
|
||||
title = it.getDisplayName(it).toTitleCase(it),
|
||||
icon = R.drawable.ic_language,
|
||||
isCloseable = true,
|
||||
data = it,
|
||||
),
|
||||
)
|
||||
}
|
||||
snapshot.types.forEach {
|
||||
result.addFirst(
|
||||
ChipsView.ChipModel(
|
||||
titleResId = it.titleResId,
|
||||
isCloseable = true,
|
||||
data = it,
|
||||
),
|
||||
)
|
||||
}
|
||||
snapshot.demographics.forEach {
|
||||
result.addFirst(
|
||||
ChipsView.ChipModel(
|
||||
titleResId = it.titleResId,
|
||||
isCloseable = true,
|
||||
data = it,
|
||||
),
|
||||
)
|
||||
}
|
||||
snapshot.contentRating.forEach {
|
||||
result.addFirst(
|
||||
ChipsView.ChipModel(
|
||||
titleResId = it.titleResId,
|
||||
isCloseable = true,
|
||||
data = it,
|
||||
),
|
||||
)
|
||||
}
|
||||
snapshot.states.forEach {
|
||||
result.addFirst(
|
||||
ChipsView.ChipModel(
|
||||
titleResId = it.titleResId,
|
||||
isCloseable = true,
|
||||
data = it,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (!snapshot.query.isNullOrEmpty()) {
|
||||
result.addFirst(
|
||||
ChipsView.ChipModel(
|
||||
title = snapshot.query,
|
||||
icon = appcompatR.drawable.abc_ic_search_api_material,
|
||||
isCloseable = true,
|
||||
data = snapshot.query,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (!snapshot.author.isNullOrEmpty()) {
|
||||
result.addFirst(
|
||||
ChipsView.ChipModel(
|
||||
title = snapshot.author,
|
||||
icon = R.drawable.ic_user,
|
||||
isCloseable = true,
|
||||
data = snapshot.author,
|
||||
),
|
||||
)
|
||||
}
|
||||
val hasTags = result.any { it.data is MangaTag }
|
||||
if (hasTags) {
|
||||
result.addFirst(moreTagsChip())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun moreTagsChip() = ChipsView.ChipModel(
|
||||
titleResId = R.string.genres,
|
||||
icon = R.drawable.ic_drawer_menu_open,
|
||||
)
|
||||
private fun moreTagsChip() = ChipsView.ChipModel(
|
||||
titleResId = R.string.genres,
|
||||
icon = R.drawable.ic_drawer_menu_open,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
package org.koitharu.kotatsu.filter.ui.sheet
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.InputFilter
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.slider.RangeSlider
|
||||
import com.google.android.material.slider.Slider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.titleResId
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.ui.dialog.setEditText
|
||||
import org.koitharu.kotatsu.core.ui.model.titleRes
|
||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.util.AlphanumComparator
|
||||
import org.koitharu.kotatsu.core.util.ext.consume
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||
@@ -27,6 +42,8 @@ import org.koitharu.kotatsu.core.util.ext.parentView
|
||||
import org.koitharu.kotatsu.core.util.ext.setValueRounded
|
||||
import org.koitharu.kotatsu.core.util.ext.setValuesRounded
|
||||
import org.koitharu.kotatsu.databinding.SheetFilterBinding
|
||||
import org.koitharu.kotatsu.filter.data.PersistableFilter
|
||||
import org.koitharu.kotatsu.filter.data.PersistableFilter.Companion.MAX_TITLE_LENGTH
|
||||
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
@@ -36,322 +53,499 @@ import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.toIntUp
|
||||
import java.util.Locale
|
||||
import java.util.TreeSet
|
||||
|
||||
class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
AdapterView.OnItemSelectedListener,
|
||||
ChipsView.OnChipClickListener {
|
||||
AdapterView.OnItemSelectedListener,
|
||||
View.OnClickListener,
|
||||
ChipsView.OnChipClickListener,
|
||||
ChipsView.OnChipLongClickListener,
|
||||
ChipsView.OnChipCloseClickListener {
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
|
||||
return SheetFilterBinding.inflate(inflater, container, false)
|
||||
}
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
|
||||
return SheetFilterBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
if (dialog == null) {
|
||||
binding.layoutBody.updatePadding(top = binding.layoutBody.paddingBottom)
|
||||
binding.scrollView.scrollIndicators = 0
|
||||
}
|
||||
val filter = FilterCoordinator.require(this)
|
||||
filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
|
||||
filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged)
|
||||
filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged)
|
||||
filter.tags.observe(viewLifecycleOwner, this::onTagsChanged)
|
||||
filter.tagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged)
|
||||
filter.states.observe(viewLifecycleOwner, this::onStateChanged)
|
||||
filter.contentTypes.observe(viewLifecycleOwner, this::onContentTypesChanged)
|
||||
filter.contentRating.observe(viewLifecycleOwner, this::onContentRatingChanged)
|
||||
filter.demographics.observe(viewLifecycleOwner, this::onDemographicsChanged)
|
||||
filter.year.observe(viewLifecycleOwner, this::onYearChanged)
|
||||
filter.yearRange.observe(viewLifecycleOwner, this::onYearRangeChanged)
|
||||
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
if (dialog == null) {
|
||||
binding.adjustForEmbeddedLayout()
|
||||
}
|
||||
val filter = FilterCoordinator.require(this)
|
||||
filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
|
||||
filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged)
|
||||
filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged)
|
||||
filter.tags.observe(viewLifecycleOwner, this::onTagsChanged)
|
||||
filter.tagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged)
|
||||
filter.authors.observe(viewLifecycleOwner, this::onAuthorsChanged)
|
||||
filter.states.observe(viewLifecycleOwner, this::onStateChanged)
|
||||
filter.contentTypes.observe(viewLifecycleOwner, this::onContentTypesChanged)
|
||||
filter.contentRating.observe(viewLifecycleOwner, this::onContentRatingChanged)
|
||||
filter.demographics.observe(viewLifecycleOwner, this::onDemographicsChanged)
|
||||
filter.year.observe(viewLifecycleOwner, this::onYearChanged)
|
||||
filter.yearRange.observe(viewLifecycleOwner, this::onYearRangeChanged)
|
||||
filter.savedFilters.observe(viewLifecycleOwner, ::onSavedPresetsChanged)
|
||||
|
||||
binding.layoutGenres.setTitle(
|
||||
if (filter.capabilities.isMultipleTagsSupported) {
|
||||
R.string.genres
|
||||
} else {
|
||||
R.string.genre
|
||||
},
|
||||
)
|
||||
binding.spinnerLocale.onItemSelectedListener = this
|
||||
binding.spinnerOriginalLocale.onItemSelectedListener = this
|
||||
binding.spinnerOrder.onItemSelectedListener = this
|
||||
binding.chipsState.onChipClickListener = this
|
||||
binding.chipsTypes.onChipClickListener = this
|
||||
binding.chipsContentRating.onChipClickListener = this
|
||||
binding.chipsDemographics.onChipClickListener = this
|
||||
binding.chipsGenres.onChipClickListener = this
|
||||
binding.chipsGenresExclude.onChipClickListener = this
|
||||
binding.sliderYear.addOnChangeListener(this::onSliderValueChange)
|
||||
binding.sliderYearsRange.addOnChangeListener(this::onRangeSliderValueChange)
|
||||
binding.layoutGenres.setOnMoreButtonClickListener {
|
||||
router.showTagsCatalogSheet(excludeMode = false)
|
||||
}
|
||||
binding.layoutGenresExclude.setOnMoreButtonClickListener {
|
||||
router.showTagsCatalogSheet(excludeMode = true)
|
||||
}
|
||||
}
|
||||
binding.layoutGenres.setTitle(
|
||||
if (filter.capabilities.isMultipleTagsSupported) {
|
||||
R.string.genres
|
||||
} else {
|
||||
R.string.genre
|
||||
},
|
||||
)
|
||||
binding.spinnerLocale.onItemSelectedListener = this
|
||||
binding.spinnerOriginalLocale.onItemSelectedListener = this
|
||||
binding.spinnerOrder.onItemSelectedListener = this
|
||||
binding.chipsSavedFilters.onChipClickListener = this
|
||||
binding.chipsState.onChipClickListener = this
|
||||
binding.chipsTypes.onChipClickListener = this
|
||||
binding.chipsContentRating.onChipClickListener = this
|
||||
binding.chipsDemographics.onChipClickListener = this
|
||||
binding.chipsGenres.onChipClickListener = this
|
||||
binding.chipsGenresExclude.onChipClickListener = this
|
||||
binding.chipsAuthor.onChipClickListener = this
|
||||
binding.chipsSavedFilters.onChipLongClickListener = this
|
||||
binding.chipsSavedFilters.onChipCloseClickListener = this
|
||||
binding.sliderYear.addOnChangeListener(this::onSliderValueChange)
|
||||
binding.sliderYearsRange.addOnChangeListener(this::onRangeSliderValueChange)
|
||||
binding.layoutGenres.setOnMoreButtonClickListener {
|
||||
router.showTagsCatalogSheet(excludeMode = false)
|
||||
}
|
||||
binding.layoutGenresExclude.setOnMoreButtonClickListener {
|
||||
router.showTagsCatalogSheet(excludeMode = true)
|
||||
}
|
||||
combine(
|
||||
filter.observe().map { it.listFilter.isNotEmpty() }.distinctUntilChanged(),
|
||||
filter.savedFilters.map { it.selectedItems.isEmpty() }.distinctUntilChanged(),
|
||||
Boolean::and,
|
||||
).flowOn(Dispatchers.Default)
|
||||
.observe(viewLifecycleOwner) {
|
||||
binding.buttonSave.isEnabled = it
|
||||
}
|
||||
binding.buttonSave.setOnClickListener(this)
|
||||
binding.buttonDone.setOnClickListener(this)
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val typeMask = WindowInsetsCompat.Type.systemBars()
|
||||
viewBinding?.scrollView?.updatePadding(
|
||||
bottom = insets.getInsets(typeMask).bottom,
|
||||
)
|
||||
return insets.consume(v, typeMask, bottom = true)
|
||||
}
|
||||
private fun SheetFilterBinding.adjustForEmbeddedLayout() {
|
||||
layoutBody.updatePadding(top = layoutBody.paddingBottom)
|
||||
scrollView.scrollIndicators = 0
|
||||
buttonDone.isVisible = false
|
||||
this.root.layoutParams?.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
buttonSave.updateLayoutParams<LinearLayout.LayoutParams> {
|
||||
weight = 0f
|
||||
width = LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
gravity = Gravity.END or Gravity.CENTER_VERTICAL
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
|
||||
val filter = FilterCoordinator.require(this)
|
||||
when (parent.id) {
|
||||
R.id.spinner_order -> filter.setSortOrder(filter.sortOrder.value.availableItems[position])
|
||||
R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position])
|
||||
R.id.spinner_original_locale -> filter.setOriginalLocale(filter.originalLocale.value.availableItems[position])
|
||||
}
|
||||
}
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val typeMask = WindowInsetsCompat.Type.systemBars()
|
||||
viewBinding?.layoutBottom?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
bottomMargin = insets.getInsets(typeMask).bottom
|
||||
}
|
||||
return insets.consume(v, typeMask, bottom = true)
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_done -> dismiss()
|
||||
R.id.button_save -> onSaveFilterClick("")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSliderValueChange(slider: Slider, value: Float, fromUser: Boolean) {
|
||||
if (!fromUser) {
|
||||
return
|
||||
}
|
||||
val intValue = value.toInt()
|
||||
val filter = FilterCoordinator.require(this)
|
||||
when (slider.id) {
|
||||
R.id.slider_year -> filter.setYear(
|
||||
if (intValue <= slider.valueFrom.toIntUp()) {
|
||||
YEAR_UNKNOWN
|
||||
} else {
|
||||
intValue
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
|
||||
val filter = FilterCoordinator.require(this)
|
||||
when (parent.id) {
|
||||
R.id.spinner_order -> filter.setSortOrder(filter.sortOrder.value.availableItems[position])
|
||||
R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position])
|
||||
R.id.spinner_original_locale -> filter.setOriginalLocale(filter.originalLocale.value.availableItems[position])
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRangeSliderValueChange(slider: RangeSlider, value: Float, fromUser: Boolean) {
|
||||
if (!fromUser) {
|
||||
return
|
||||
}
|
||||
val filter = FilterCoordinator.require(this)
|
||||
when (slider.id) {
|
||||
R.id.slider_yearsRange -> filter.setYearRange(
|
||||
valueFrom = slider.values.firstOrNull()?.let {
|
||||
if (it <= slider.valueFrom) YEAR_UNKNOWN else it.toInt()
|
||||
} ?: YEAR_UNKNOWN,
|
||||
valueTo = slider.values.lastOrNull()?.let {
|
||||
if (it >= slider.valueTo) YEAR_UNKNOWN else it.toInt()
|
||||
} ?: YEAR_UNKNOWN,
|
||||
)
|
||||
}
|
||||
}
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||
|
||||
override fun onChipClick(chip: Chip, data: Any?) {
|
||||
val filter = FilterCoordinator.require(this)
|
||||
when (data) {
|
||||
is MangaState -> filter.toggleState(data, !chip.isChecked)
|
||||
is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) {
|
||||
filter.toggleTagExclude(data, !chip.isChecked)
|
||||
} else {
|
||||
filter.toggleTag(data, !chip.isChecked)
|
||||
}
|
||||
private fun onSliderValueChange(slider: Slider, value: Float, fromUser: Boolean) {
|
||||
if (!fromUser) {
|
||||
return
|
||||
}
|
||||
val intValue = value.toInt()
|
||||
val filter = FilterCoordinator.require(this)
|
||||
when (slider.id) {
|
||||
R.id.slider_year -> filter.setYear(
|
||||
if (intValue <= slider.valueFrom.toIntUp()) {
|
||||
YEAR_UNKNOWN
|
||||
} else {
|
||||
intValue
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ContentType -> filter.toggleContentType(data, !chip.isChecked)
|
||||
is ContentRating -> filter.toggleContentRating(data, !chip.isChecked)
|
||||
is Demographic -> filter.toggleDemographic(data, !chip.isChecked)
|
||||
null -> router.showTagsCatalogSheet(excludeMode = chip.parentView?.id == R.id.chips_genresExclude)
|
||||
}
|
||||
}
|
||||
private fun onRangeSliderValueChange(slider: RangeSlider, value: Float, fromUser: Boolean) {
|
||||
if (!fromUser) {
|
||||
return
|
||||
}
|
||||
val filter = FilterCoordinator.require(this)
|
||||
when (slider.id) {
|
||||
R.id.slider_yearsRange -> filter.setYearRange(
|
||||
valueFrom = slider.values.firstOrNull()?.let {
|
||||
if (it <= slider.valueFrom) YEAR_UNKNOWN else it.toInt()
|
||||
} ?: YEAR_UNKNOWN,
|
||||
valueTo = slider.values.lastOrNull()?.let {
|
||||
if (it >= slider.valueTo) YEAR_UNKNOWN else it.toInt()
|
||||
} ?: YEAR_UNKNOWN,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSortOrderChanged(value: FilterProperty<SortOrder>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutOrder.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val selected = value.selectedItems.single()
|
||||
b.spinnerOrder.adapter = ArrayAdapter(
|
||||
b.spinnerOrder.context,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
android.R.id.text1,
|
||||
value.availableItems.map { b.spinnerOrder.context.getString(it.titleRes) },
|
||||
)
|
||||
val selectedIndex = value.availableItems.indexOf(selected)
|
||||
if (selectedIndex >= 0) {
|
||||
b.spinnerOrder.setSelection(selectedIndex, false)
|
||||
}
|
||||
}
|
||||
override fun onChipClick(chip: Chip, data: Any?) {
|
||||
val filter = FilterCoordinator.require(this)
|
||||
when (data) {
|
||||
is MangaState -> filter.toggleState(data, !chip.isChecked)
|
||||
is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) {
|
||||
filter.toggleTagExclude(data, !chip.isChecked)
|
||||
} else {
|
||||
filter.toggleTag(data, !chip.isChecked)
|
||||
}
|
||||
|
||||
private fun onLocaleChanged(value: FilterProperty<Locale?>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutLocale.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val selected = value.selectedItems.singleOrNull()
|
||||
b.spinnerLocale.adapter = ArrayAdapter(
|
||||
b.spinnerLocale.context,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
android.R.id.text1,
|
||||
value.availableItems.map { it.getDisplayName(b.spinnerLocale.context) },
|
||||
)
|
||||
val selectedIndex = value.availableItems.indexOf(selected)
|
||||
if (selectedIndex >= 0) {
|
||||
b.spinnerLocale.setSelection(selectedIndex, false)
|
||||
}
|
||||
}
|
||||
is ContentType -> filter.toggleContentType(data, !chip.isChecked)
|
||||
is ContentRating -> filter.toggleContentRating(data, !chip.isChecked)
|
||||
is Demographic -> filter.toggleDemographic(data, !chip.isChecked)
|
||||
is PersistableFilter -> filter.setAdjusted(data.filter)
|
||||
is String -> if (chip.isChecked) {
|
||||
filter.setAuthor(null)
|
||||
} else {
|
||||
filter.setAuthor(data)
|
||||
}
|
||||
null -> router.showTagsCatalogSheet(excludeMode = chip.parentView?.id == R.id.chips_genresExclude)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onOriginalLocaleChanged(value: FilterProperty<Locale?>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutOriginalLocale.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val selected = value.selectedItems.singleOrNull()
|
||||
b.spinnerOriginalLocale.adapter = ArrayAdapter(
|
||||
b.spinnerOriginalLocale.context,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
android.R.id.text1,
|
||||
value.availableItems.map { it.getDisplayName(b.spinnerOriginalLocale.context) },
|
||||
)
|
||||
val selectedIndex = value.availableItems.indexOf(selected)
|
||||
if (selectedIndex >= 0) {
|
||||
b.spinnerOriginalLocale.setSelection(selectedIndex, false)
|
||||
}
|
||||
}
|
||||
override fun onChipLongClick(chip: Chip, data: Any?): Boolean {
|
||||
return when (data) {
|
||||
is PersistableFilter -> {
|
||||
showSavedFilterMenu(chip, data)
|
||||
true
|
||||
}
|
||||
|
||||
private fun onTagsChanged(value: FilterProperty<MangaTag>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutGenres.isGone = value.isEmptyAndSuccess()
|
||||
b.layoutGenres.setError(value.error?.getDisplayMessage(resources))
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val chips = value.availableItems.map { tag ->
|
||||
ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
isChecked = tag in value.selectedItems,
|
||||
data = tag,
|
||||
)
|
||||
}
|
||||
b.chipsGenres.setChips(chips)
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun onTagsExcludedChanged(value: FilterProperty<MangaTag>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutGenresExclude.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val chips = value.availableItems.map { tag ->
|
||||
ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
isChecked = tag in value.selectedItems,
|
||||
data = tag,
|
||||
)
|
||||
}
|
||||
b.chipsGenresExclude.setChips(chips)
|
||||
}
|
||||
override fun onChipCloseClick(chip: Chip, data: Any?) {
|
||||
when (data) {
|
||||
is PersistableFilter -> {
|
||||
showSavedFilterMenu(chip, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onStateChanged(value: FilterProperty<MangaState>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutState.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val chips = value.availableItems.map { state ->
|
||||
ChipsView.ChipModel(
|
||||
title = getString(state.titleResId),
|
||||
isChecked = state in value.selectedItems,
|
||||
data = state,
|
||||
)
|
||||
}
|
||||
b.chipsState.setChips(chips)
|
||||
}
|
||||
private fun onSortOrderChanged(value: FilterProperty<SortOrder>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutOrder.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val selected = value.selectedItems.single()
|
||||
b.spinnerOrder.adapter = ArrayAdapter(
|
||||
b.spinnerOrder.context,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
android.R.id.text1,
|
||||
value.availableItems.map { b.spinnerOrder.context.getString(it.titleRes) },
|
||||
)
|
||||
val selectedIndex = value.availableItems.indexOf(selected)
|
||||
if (selectedIndex >= 0) {
|
||||
b.spinnerOrder.setSelection(selectedIndex, false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onContentTypesChanged(value: FilterProperty<ContentType>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutTypes.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val chips = value.availableItems.map { type ->
|
||||
ChipsView.ChipModel(
|
||||
title = getString(type.titleResId),
|
||||
isChecked = type in value.selectedItems,
|
||||
data = type,
|
||||
)
|
||||
}
|
||||
b.chipsTypes.setChips(chips)
|
||||
}
|
||||
private fun onLocaleChanged(value: FilterProperty<Locale?>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutLocale.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val selected = value.selectedItems.singleOrNull()
|
||||
b.spinnerLocale.adapter = ArrayAdapter(
|
||||
b.spinnerLocale.context,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
android.R.id.text1,
|
||||
value.availableItems.map { it.getDisplayName(b.spinnerLocale.context) },
|
||||
)
|
||||
val selectedIndex = value.availableItems.indexOf(selected)
|
||||
if (selectedIndex >= 0) {
|
||||
b.spinnerLocale.setSelection(selectedIndex, false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onContentRatingChanged(value: FilterProperty<ContentRating>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutContentRating.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val chips = value.availableItems.map { contentRating ->
|
||||
ChipsView.ChipModel(
|
||||
title = getString(contentRating.titleResId),
|
||||
isChecked = contentRating in value.selectedItems,
|
||||
data = contentRating,
|
||||
)
|
||||
}
|
||||
b.chipsContentRating.setChips(chips)
|
||||
}
|
||||
private fun onOriginalLocaleChanged(value: FilterProperty<Locale?>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutOriginalLocale.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val selected = value.selectedItems.singleOrNull()
|
||||
b.spinnerOriginalLocale.adapter = ArrayAdapter(
|
||||
b.spinnerOriginalLocale.context,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
android.R.id.text1,
|
||||
value.availableItems.map { it.getDisplayName(b.spinnerOriginalLocale.context) },
|
||||
)
|
||||
val selectedIndex = value.availableItems.indexOf(selected)
|
||||
if (selectedIndex >= 0) {
|
||||
b.spinnerOriginalLocale.setSelection(selectedIndex, false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDemographicsChanged(value: FilterProperty<Demographic>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutDemographics.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val chips = value.availableItems.map { demographic ->
|
||||
ChipsView.ChipModel(
|
||||
title = getString(demographic.titleResId),
|
||||
isChecked = demographic in value.selectedItems,
|
||||
data = demographic,
|
||||
)
|
||||
}
|
||||
b.chipsDemographics.setChips(chips)
|
||||
}
|
||||
private fun onTagsChanged(value: FilterProperty<MangaTag>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutGenres.isGone = value.isEmptyAndSuccess()
|
||||
b.layoutGenres.setError(value.error?.getDisplayMessage(resources))
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val chips = value.availableItems.map { tag ->
|
||||
ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
isChecked = tag in value.selectedItems,
|
||||
data = tag,
|
||||
)
|
||||
}
|
||||
b.chipsGenres.setChips(chips)
|
||||
}
|
||||
|
||||
private fun onYearChanged(value: FilterProperty<Int>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutYear.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val currentValue = value.selectedItems.singleOrNull() ?: YEAR_UNKNOWN
|
||||
b.layoutYear.setValueText(
|
||||
if (currentValue == YEAR_UNKNOWN) {
|
||||
getString(R.string.any)
|
||||
} else {
|
||||
currentValue.toString()
|
||||
},
|
||||
)
|
||||
b.sliderYear.valueFrom = value.availableItems.first().toFloat()
|
||||
b.sliderYear.valueTo = value.availableItems.last().toFloat()
|
||||
b.sliderYear.setValueRounded(currentValue.toFloat())
|
||||
}
|
||||
private fun onTagsExcludedChanged(value: FilterProperty<MangaTag>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutGenresExclude.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val chips = value.availableItems.map { tag ->
|
||||
ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
isChecked = tag in value.selectedItems,
|
||||
data = tag,
|
||||
)
|
||||
}
|
||||
b.chipsGenresExclude.setChips(chips)
|
||||
}
|
||||
|
||||
private fun onYearRangeChanged(value: FilterProperty<Int>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutYearsRange.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
b.sliderYearsRange.valueFrom = value.availableItems.first().toFloat()
|
||||
b.sliderYearsRange.valueTo = value.availableItems.last().toFloat()
|
||||
val currentValueFrom = value.selectedItems.firstOrNull()?.toFloat() ?: b.sliderYearsRange.valueFrom
|
||||
val currentValueTo = value.selectedItems.lastOrNull()?.toFloat() ?: b.sliderYearsRange.valueTo
|
||||
b.layoutYearsRange.setValueText(
|
||||
getString(
|
||||
R.string.memory_usage_pattern,
|
||||
currentValueFrom.toInt().toString(),
|
||||
currentValueTo.toInt().toString(),
|
||||
),
|
||||
)
|
||||
b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo)
|
||||
}
|
||||
private fun onAuthorsChanged(value: FilterProperty<String>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutAuthor.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val chips = value.availableItems.map { author ->
|
||||
ChipsView.ChipModel(
|
||||
title = author,
|
||||
isChecked = author in value.selectedItems,
|
||||
data = author,
|
||||
)
|
||||
}
|
||||
b.chipsAuthor.setChips(chips)
|
||||
}
|
||||
|
||||
private fun onStateChanged(value: FilterProperty<MangaState>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutState.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val chips = value.availableItems.map { state ->
|
||||
ChipsView.ChipModel(
|
||||
title = getString(state.titleResId),
|
||||
isChecked = state in value.selectedItems,
|
||||
data = state,
|
||||
)
|
||||
}
|
||||
b.chipsState.setChips(chips)
|
||||
}
|
||||
|
||||
private fun onContentTypesChanged(value: FilterProperty<ContentType>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutTypes.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val chips = value.availableItems.map { type ->
|
||||
ChipsView.ChipModel(
|
||||
title = getString(type.titleResId),
|
||||
isChecked = type in value.selectedItems,
|
||||
data = type,
|
||||
)
|
||||
}
|
||||
b.chipsTypes.setChips(chips)
|
||||
}
|
||||
|
||||
private fun onContentRatingChanged(value: FilterProperty<ContentRating>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutContentRating.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val chips = value.availableItems.map { contentRating ->
|
||||
ChipsView.ChipModel(
|
||||
title = getString(contentRating.titleResId),
|
||||
isChecked = contentRating in value.selectedItems,
|
||||
data = contentRating,
|
||||
)
|
||||
}
|
||||
b.chipsContentRating.setChips(chips)
|
||||
}
|
||||
|
||||
private fun onDemographicsChanged(value: FilterProperty<Demographic>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutDemographics.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val chips = value.availableItems.map { demographic ->
|
||||
ChipsView.ChipModel(
|
||||
title = getString(demographic.titleResId),
|
||||
isChecked = demographic in value.selectedItems,
|
||||
data = demographic,
|
||||
)
|
||||
}
|
||||
b.chipsDemographics.setChips(chips)
|
||||
}
|
||||
|
||||
private fun onYearChanged(value: FilterProperty<Int>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutYear.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val currentValue = value.selectedItems.singleOrNull() ?: YEAR_UNKNOWN
|
||||
b.layoutYear.setValueText(
|
||||
if (currentValue == YEAR_UNKNOWN) {
|
||||
getString(R.string.any)
|
||||
} else {
|
||||
currentValue.toString()
|
||||
},
|
||||
)
|
||||
b.sliderYear.valueFrom = value.availableItems.first().toFloat()
|
||||
b.sliderYear.valueTo = value.availableItems.last().toFloat()
|
||||
b.sliderYear.setValueRounded(currentValue.toFloat())
|
||||
}
|
||||
|
||||
private fun onYearRangeChanged(value: FilterProperty<Int>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutYearsRange.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
b.sliderYearsRange.valueFrom = value.availableItems.first().toFloat()
|
||||
b.sliderYearsRange.valueTo = value.availableItems.last().toFloat()
|
||||
val currentValueFrom = value.selectedItems.firstOrNull()?.toFloat() ?: b.sliderYearsRange.valueFrom
|
||||
val currentValueTo = value.selectedItems.lastOrNull()?.toFloat() ?: b.sliderYearsRange.valueTo
|
||||
b.layoutYearsRange.setValueText(
|
||||
getString(
|
||||
R.string.memory_usage_pattern,
|
||||
currentValueFrom.toInt().toString(),
|
||||
currentValueTo.toInt().toString(),
|
||||
),
|
||||
)
|
||||
b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo)
|
||||
}
|
||||
|
||||
private fun onSavedPresetsChanged(value: FilterProperty<PersistableFilter>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutSavedFilters.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val chips = value.availableItems.map { f ->
|
||||
ChipsView.ChipModel(
|
||||
title = f.name,
|
||||
isChecked = f in value.selectedItems,
|
||||
data = f,
|
||||
isDropdown = true,
|
||||
)
|
||||
}
|
||||
b.chipsSavedFilters.setChips(chips)
|
||||
}
|
||||
|
||||
private fun showSavedFilterMenu(anchor: View, preset: PersistableFilter) {
|
||||
val menu = PopupMenu(context ?: return, anchor)
|
||||
val filter = FilterCoordinator.require(this)
|
||||
menu.inflate(R.menu.popup_saved_filter)
|
||||
menu.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_delete -> filter.deleteSavedFilter(preset.id)
|
||||
R.id.action_rename -> onRenameFilterClick(preset)
|
||||
}
|
||||
true
|
||||
}
|
||||
menu.show()
|
||||
}
|
||||
|
||||
private fun onSaveFilterClick(name: String) {
|
||||
val filter = FilterCoordinator.require(this)
|
||||
val existingNames = filter.savedFilters.value.availableItems
|
||||
.mapTo(TreeSet(AlphanumComparator()), PersistableFilter::name)
|
||||
buildAlertDialog(context ?: return) {
|
||||
val input = setEditText(
|
||||
entries = existingNames.toList(),
|
||||
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES,
|
||||
singleLine = true,
|
||||
)
|
||||
input.setHint(R.string.enter_name)
|
||||
input.setText(name)
|
||||
input.filters += InputFilter.LengthFilter(MAX_TITLE_LENGTH)
|
||||
setTitle(R.string.save_filter)
|
||||
setPositiveButton(R.string.save) { _, _ ->
|
||||
val text = input.text?.toString()?.trim()
|
||||
if (text.isNullOrEmpty()) {
|
||||
Toast.makeText(context, R.string.invalid_value_message, Toast.LENGTH_SHORT).show()
|
||||
onSaveFilterClick("")
|
||||
} else if (text in existingNames) {
|
||||
askForFilterOverwrite(filter, text)
|
||||
} else {
|
||||
filter.saveCurrentFilter(text)
|
||||
}
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun onRenameFilterClick(preset: PersistableFilter) {
|
||||
val filter = FilterCoordinator.require(this)
|
||||
val existingNames = filter.savedFilters.value.availableItems.mapToSet { it.name }
|
||||
buildAlertDialog(context ?: return) {
|
||||
val input = setEditText(
|
||||
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES,
|
||||
singleLine = true,
|
||||
)
|
||||
input.filters += InputFilter.LengthFilter(MAX_TITLE_LENGTH)
|
||||
input.setHint(R.string.enter_name)
|
||||
input.setText(preset.name)
|
||||
setTitle(R.string.rename)
|
||||
setPositiveButton(R.string.save) { _, _ ->
|
||||
val text = input.text?.toString()?.trim()
|
||||
if (text.isNullOrEmpty() || text in existingNames) {
|
||||
Toast.makeText(context, R.string.invalid_value_message, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
filter.renameSavedFilter(preset.id, text)
|
||||
}
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun askForFilterOverwrite(filter: FilterCoordinator, name: String) {
|
||||
buildAlertDialog(context ?: return) {
|
||||
setTitle(R.string.save_filter)
|
||||
setMessage(getString(R.string.filter_overwrite_confirm, name))
|
||||
setPositiveButton(R.string.overwrite) { _, _ ->
|
||||
filter.saveCurrentFilter(name)
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
onSaveFilterClick(name)
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@ package org.koitharu.kotatsu.image.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.ViewTreeObserver.OnPreDrawListener
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import androidx.core.graphics.ColorUtils
|
||||
@@ -205,6 +203,7 @@ class CoverImageView @JvmOverloads constructor(
|
||||
is HttpStatusException -> statusCode.toString()
|
||||
is ContentUnavailableException,
|
||||
is FileNotFoundException -> "404"
|
||||
|
||||
is TooManyRequestExceptions -> "429"
|
||||
is ParseException -> "</>"
|
||||
is UnsupportedSourceException -> "X"
|
||||
@@ -266,7 +265,7 @@ class CoverImageView @JvmOverloads constructor(
|
||||
width = Dimension(height.px * view.aspectRationWidth / view.aspectRationHeight)
|
||||
}
|
||||
}
|
||||
return Size(checkNotNull(width), checkNotNull(height))
|
||||
return Size(width, height)
|
||||
}
|
||||
|
||||
private fun getWidth() = getDimension(
|
||||
|
||||
@@ -13,11 +13,11 @@ import androidx.annotation.WorkerThread
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toFile
|
||||
import dagger.Reusable
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Cache
|
||||
import org.koitharu.kotatsu.core.LocalizedAppContext
|
||||
import org.koitharu.kotatsu.core.exceptions.NonFileUriException
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.computeSize
|
||||
@@ -39,8 +39,8 @@ private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
|
||||
|
||||
@Reusable
|
||||
class LocalStorageManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val settings: AppSettings,
|
||||
@LocalizedAppContext private val context: Context,
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
val contentResolver: ContentResolver
|
||||
|
||||
@@ -27,7 +27,6 @@ import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
||||
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
|
||||
import org.koitharu.kotatsu.parsers.util.json.toStringSet
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import java.io.File
|
||||
|
||||
@@ -61,7 +61,9 @@ class LocalMangaParser(private val uri: Uri) {
|
||||
val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX)
|
||||
val mangaInfo = index?.getMangaInfo()
|
||||
if (mangaInfo != null) {
|
||||
val coverEntry: Path? = index.getCoverEntry()?.let { rootPath / it }
|
||||
val coverEntry: Path? = index.getCoverEntry()?.let { rootPath / it }?.takeIf {
|
||||
fileSystem.exists(it)
|
||||
}
|
||||
mangaInfo.copy(
|
||||
source = LocalMangaSource,
|
||||
url = rootFile.toUri().toString(),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.koitharu.kotatsu.main.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.app.BackgroundServiceStartNotAllowedException
|
||||
import android.app.ServiceStartNotAllowedException
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.os.Build
|
||||
@@ -58,6 +60,7 @@ import org.koitharu.kotatsu.core.util.ext.consume
|
||||
import org.koitharu.kotatsu.core.util.ext.end
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.start
|
||||
import org.koitharu.kotatsu.databinding.ActivityMainBinding
|
||||
import org.koitharu.kotatsu.details.service.MangaPrefetchService
|
||||
@@ -288,7 +291,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
|
||||
adjustFabVisibility(isResumeEnabled = isEnabled)
|
||||
}
|
||||
|
||||
private fun onFirstStart() {
|
||||
private fun onFirstStart() = try {
|
||||
lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher
|
||||
withContext(Dispatchers.Default) {
|
||||
LocalStorageCleanupWorker.enqueue(applicationContext)
|
||||
@@ -303,6 +306,8 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
e.printStackTraceDebug()
|
||||
}
|
||||
|
||||
private fun adjustAppbar(topFragment: Fragment) {
|
||||
|
||||
@@ -51,6 +51,7 @@ class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipC
|
||||
binding.chipsType.onChipClickListener = this
|
||||
binding.chipBackup.setOnClickListener(this)
|
||||
binding.chipSync.setOnClickListener(this)
|
||||
binding.chipDirectories.setOnClickListener(this)
|
||||
|
||||
viewModel.locales.observe(viewLifecycleOwner, ::onLocalesChanged)
|
||||
viewModel.types.observe(viewLifecycleOwner, ::onTypesChanged)
|
||||
@@ -86,6 +87,10 @@ class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipC
|
||||
val accountType = getString(R.string.account_type_sync)
|
||||
am.addAccount(accountType, accountType, null, null, requireActivity(), null, null)
|
||||
}
|
||||
|
||||
R.id.chip_directories -> {
|
||||
router.openDirectoriesSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.reader.domain
|
||||
|
||||
import android.util.LongSparseArray
|
||||
import androidx.annotation.CheckResult
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -32,12 +33,12 @@ class ChaptersLoader @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadPrevNextChapter(manga: MangaDetails, currentId: Long, isNext: Boolean) {
|
||||
suspend fun loadPrevNextChapter(manga: MangaDetails, currentId: Long, isNext: Boolean): Boolean {
|
||||
val chapters = manga.allChapters
|
||||
val predicate: (MangaChapter) -> Boolean = { it.id == currentId }
|
||||
val index = if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate)
|
||||
if (index == -1) return
|
||||
val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return
|
||||
if (index == -1) return false
|
||||
val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return false
|
||||
val newPages = loadChapter(newChapter.id)
|
||||
mutex.withLock {
|
||||
if (chapterPages.chaptersSize > 1) {
|
||||
@@ -56,13 +57,16 @@ class ChaptersLoader @Inject constructor(
|
||||
chapterPages.addFirst(newChapter.id, newPages)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun loadSingleChapter(chapterId: Long) {
|
||||
@CheckResult
|
||||
suspend fun loadSingleChapter(chapterId: Long): Boolean {
|
||||
val pages = loadChapter(chapterId)
|
||||
mutex.withLock {
|
||||
return mutex.withLock {
|
||||
chapterPages.clear()
|
||||
chapterPages.addLast(chapterId, pages)
|
||||
pages.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,9 @@ import android.graphics.Rect
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.graphics.alpha
|
||||
import androidx.core.graphics.blue
|
||||
import androidx.core.graphics.get
|
||||
import androidx.core.graphics.green
|
||||
import androidx.core.graphics.red
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.decoder.ImageRegionDecoder
|
||||
import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@@ -23,7 +21,6 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.util.SynchronizedSieveCache
|
||||
import org.koitharu.kotatsu.core.util.ext.use
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
@@ -46,19 +43,19 @@ class EdgeDetector(private val context: Context) {
|
||||
}
|
||||
val scaleFactor = calculateScaleFactor(size)
|
||||
val sampleSize = (1f / scaleFactor).toInt().coerceAtLeast(1)
|
||||
|
||||
|
||||
val fullBitmap = decoder.decodeRegion(
|
||||
Rect(0, 0, size.x, size.y),
|
||||
sampleSize
|
||||
Rect(0, 0, size.x, size.y),
|
||||
sampleSize,
|
||||
)
|
||||
|
||||
|
||||
try {
|
||||
val edges = coroutineScope {
|
||||
listOf(
|
||||
async { detectLeftRightEdge(fullBitmap, size, sampleSize, isLeft = true) },
|
||||
async { detectTopBottomEdge(fullBitmap, size, sampleSize, isTop = true) },
|
||||
async { detectLeftRightEdge(fullBitmap, size, sampleSize, isLeft = false) },
|
||||
async { detectTopBottomEdge(fullBitmap, size, sampleSize, isTop = false) },
|
||||
async { detectLeftRightEdge(fullBitmap, size, sampleSize, isLeft = true) },
|
||||
async { detectTopBottomEdge(fullBitmap, size, sampleSize, isTop = true) },
|
||||
async { detectLeftRightEdge(fullBitmap, size, sampleSize, isLeft = false) },
|
||||
async { detectTopBottomEdge(fullBitmap, size, sampleSize, isTop = false) },
|
||||
).awaitAll()
|
||||
}
|
||||
var hasEdges = false
|
||||
@@ -91,10 +88,10 @@ class EdgeDetector(private val context: Context) {
|
||||
val rectCount = size.x / BLOCK_SIZE
|
||||
val maxRect = rectCount / 3
|
||||
val blockPixels = IntArray(BLOCK_SIZE * BLOCK_SIZE)
|
||||
|
||||
|
||||
val bitmapWidth = bitmap.width
|
||||
val bitmapHeight = bitmap.height
|
||||
|
||||
|
||||
for (i in 0 until rectCount) {
|
||||
if (i > maxRect) {
|
||||
return -1
|
||||
@@ -103,16 +100,16 @@ class EdgeDetector(private val context: Context) {
|
||||
for (j in 0 until size.y / BLOCK_SIZE) {
|
||||
val regionX = if (isLeft) i * BLOCK_SIZE else size.x - (i + 1) * BLOCK_SIZE
|
||||
val regionY = j * BLOCK_SIZE
|
||||
|
||||
|
||||
// Convert to bitmap coordinates
|
||||
val bitmapX = regionX / sampleSize
|
||||
val bitmapY = regionY / sampleSize
|
||||
val blockWidth = min(BLOCK_SIZE / sampleSize, bitmapWidth - bitmapX)
|
||||
val blockHeight = min(BLOCK_SIZE / sampleSize, bitmapHeight - bitmapY)
|
||||
|
||||
|
||||
if (blockWidth > 0 && blockHeight > 0) {
|
||||
bitmap.getPixels(blockPixels, 0, blockWidth, bitmapX, bitmapY, blockWidth, blockHeight)
|
||||
|
||||
|
||||
for (ii in 0 until minOf(blockWidth, dd / sampleSize)) {
|
||||
for (jj in 0 until blockHeight) {
|
||||
val bi = if (isLeft) ii else blockWidth - ii - 1
|
||||
@@ -141,10 +138,10 @@ class EdgeDetector(private val context: Context) {
|
||||
val rectCount = size.y / BLOCK_SIZE
|
||||
val maxRect = rectCount / 3
|
||||
val blockPixels = IntArray(BLOCK_SIZE * BLOCK_SIZE)
|
||||
|
||||
|
||||
val bitmapWidth = bitmap.width
|
||||
val bitmapHeight = bitmap.height
|
||||
|
||||
|
||||
for (j in 0 until rectCount) {
|
||||
if (j > maxRect) {
|
||||
return -1
|
||||
@@ -153,16 +150,16 @@ class EdgeDetector(private val context: Context) {
|
||||
for (i in 0 until size.x / BLOCK_SIZE) {
|
||||
val regionX = i * BLOCK_SIZE
|
||||
val regionY = if (isTop) j * BLOCK_SIZE else size.y - (j + 1) * BLOCK_SIZE
|
||||
|
||||
|
||||
// Convert to bitmap coordinates
|
||||
val bitmapX = regionX / sampleSize
|
||||
val bitmapY = regionY / sampleSize
|
||||
val blockWidth = min(BLOCK_SIZE / sampleSize, bitmapWidth - bitmapX)
|
||||
val blockHeight = min(BLOCK_SIZE / sampleSize, bitmapHeight - bitmapY)
|
||||
|
||||
|
||||
if (blockWidth > 0 && blockHeight > 0) {
|
||||
bitmap.getPixels(blockPixels, 0, blockWidth, bitmapX, bitmapY, blockWidth, blockHeight)
|
||||
|
||||
|
||||
for (jj in 0 until minOf(blockHeight, dd / sampleSize)) {
|
||||
for (ii in 0 until blockWidth) {
|
||||
val bj = if (isTop) jj else blockHeight - jj - 1
|
||||
@@ -218,4 +215,4 @@ class EdgeDetector(private val context: Context) {
|
||||
|
||||
private fun region(x: Int, y: Int) = Rect(x, y, x + BLOCK_SIZE, y + BLOCK_SIZE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui
|
||||
import android.app.assist.AssistContent
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.KeyEvent
|
||||
@@ -24,6 +25,8 @@ import androidx.transition.Fade
|
||||
import androidx.transition.Slide
|
||||
import androidx.transition.TransitionManager
|
||||
import androidx.transition.TransitionSet
|
||||
import androidx.window.layout.FoldingFeature
|
||||
import androidx.window.layout.WindowInfoTracker
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -31,7 +34,9 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -70,479 +75,519 @@ import androidx.appcompat.R as appcompatR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ReaderActivity :
|
||||
BaseFullscreenActivity<ActivityReaderBinding>(),
|
||||
TapGridDispatcher.OnGridTouchListener,
|
||||
ReaderConfigSheet.Callback,
|
||||
ReaderControlDelegate.OnInteractionListener,
|
||||
ReaderNavigationCallback,
|
||||
IdlingDetector.Callback,
|
||||
ZoomControl.ZoomControlListener,
|
||||
View.OnClickListener,
|
||||
ScrollTimerControlView.OnVisibilityChangeListener {
|
||||
BaseFullscreenActivity<ActivityReaderBinding>(),
|
||||
TapGridDispatcher.OnGridTouchListener,
|
||||
ReaderConfigSheet.Callback,
|
||||
ReaderControlDelegate.OnInteractionListener,
|
||||
ReaderNavigationCallback,
|
||||
IdlingDetector.Callback,
|
||||
ZoomControl.ZoomControlListener,
|
||||
View.OnClickListener,
|
||||
ScrollTimerControlView.OnVisibilityChangeListener {
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
@Inject
|
||||
lateinit var tapGridSettings: TapGridSettings
|
||||
@Inject
|
||||
lateinit var tapGridSettings: TapGridSettings
|
||||
|
||||
@Inject
|
||||
lateinit var pageSaveHelperFactory: PageSaveHelper.Factory
|
||||
@Inject
|
||||
lateinit var pageSaveHelperFactory: PageSaveHelper.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var scrollTimerFactory: ScrollTimer.Factory
|
||||
@Inject
|
||||
lateinit var scrollTimerFactory: ScrollTimer.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var screenOrientationHelper: ScreenOrientationHelper
|
||||
@Inject
|
||||
lateinit var screenOrientationHelper: ScreenOrientationHelper
|
||||
|
||||
private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this)
|
||||
private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this)
|
||||
|
||||
private val viewModel: ReaderViewModel by viewModels()
|
||||
private val viewModel: ReaderViewModel by viewModels()
|
||||
|
||||
override val readerMode: ReaderMode?
|
||||
get() = readerManager.currentMode
|
||||
override val readerMode: ReaderMode?
|
||||
get() = readerManager.currentMode
|
||||
|
||||
private lateinit var scrollTimer: ScrollTimer
|
||||
private lateinit var pageSaveHelper: PageSaveHelper
|
||||
private lateinit var touchHelper: TapGridDispatcher
|
||||
private lateinit var controlDelegate: ReaderControlDelegate
|
||||
private var gestureInsets: Insets = Insets.NONE
|
||||
private lateinit var readerManager: ReaderManager
|
||||
private val hideUiRunnable = Runnable { setUiIsVisible(false) }
|
||||
private lateinit var scrollTimer: ScrollTimer
|
||||
private lateinit var pageSaveHelper: PageSaveHelper
|
||||
private lateinit var touchHelper: TapGridDispatcher
|
||||
private lateinit var controlDelegate: ReaderControlDelegate
|
||||
private var gestureInsets: Insets = Insets.NONE
|
||||
private lateinit var readerManager: ReaderManager
|
||||
private val hideUiRunnable = Runnable { setUiIsVisible(false) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityReaderBinding.inflate(layoutInflater))
|
||||
readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings)
|
||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
|
||||
touchHelper = TapGridDispatcher(viewBinding.root, this)
|
||||
scrollTimer = scrollTimerFactory.create(resources, this, this)
|
||||
pageSaveHelper = pageSaveHelperFactory.create(this)
|
||||
controlDelegate = ReaderControlDelegate(resources, settings, tapGridSettings, this)
|
||||
viewBinding.zoomControl.listener = this
|
||||
viewBinding.actionsView.listener = this
|
||||
viewBinding.buttonTimer?.setOnClickListener(this)
|
||||
idlingDetector.bindToLifecycle(this)
|
||||
screenOrientationHelper.applySettings()
|
||||
viewModel.isBookmarkAdded.observe(this) { viewBinding.actionsView.isBookmarkAdded = it }
|
||||
scrollTimer.isActive.observe(this) {
|
||||
updateScrollTimerButton()
|
||||
viewBinding.actionsView.setTimerActive(it)
|
||||
}
|
||||
viewBinding.timerControl.onVisibilityChangeListener = this
|
||||
viewBinding.timerControl.attach(scrollTimer, this)
|
||||
if (resources.getBoolean(R.bool.is_tablet)) {
|
||||
viewBinding.timerControl.updateLayoutParams<CoordinatorLayout.LayoutParams> {
|
||||
topMargin = marginEnd + getThemeDimensionPixelOffset(appcompatR.attr.actionBarSize)
|
||||
}
|
||||
}
|
||||
// Tracks whether the foldable device is in an unfolded state (half-opened or flat)
|
||||
private var isFoldUnfolded: Boolean = false
|
||||
|
||||
viewModel.onLoadingError.observeEvent(
|
||||
this,
|
||||
DialogErrorObserver(
|
||||
host = viewBinding.container,
|
||||
fragment = null,
|
||||
resolver = exceptionResolver,
|
||||
onResolved = { isResolved ->
|
||||
if (isResolved) {
|
||||
viewModel.reload()
|
||||
} else if (viewModel.content.value.pages.isEmpty()) {
|
||||
dispatchNavigateUp()
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
viewModel.onError.observeEvent(
|
||||
this,
|
||||
SnackbarErrorObserver(
|
||||
host = viewBinding.container,
|
||||
fragment = null,
|
||||
resolver = exceptionResolver,
|
||||
onResolved = null,
|
||||
),
|
||||
)
|
||||
viewModel.readerMode.observe(this, Lifecycle.State.STARTED, this::onInitReader)
|
||||
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(viewBinding.container))
|
||||
viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged)
|
||||
combine(
|
||||
viewModel.isLoading,
|
||||
viewModel.content.map { it.pages.isNotEmpty() }.distinctUntilChanged(),
|
||||
::Pair,
|
||||
).flowOn(Dispatchers.Default)
|
||||
.observe(this, this::onLoadingStateChanged)
|
||||
viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn)
|
||||
viewModel.isInfoBarTransparent.observe(this) { viewBinding.infoBar.drawBackground = !it }
|
||||
viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged)
|
||||
viewModel.isBookmarkAdded.observe(this, MenuInvalidator(this))
|
||||
viewModel.onAskNsfwIncognito.observeEvent(this) { askForIncognitoMode() }
|
||||
viewModel.onShowToast.observeEvent(this) { msgId ->
|
||||
Snackbar.make(viewBinding.container, msgId, Snackbar.LENGTH_SHORT)
|
||||
.setAnchorView(viewBinding.toolbarDocked)
|
||||
.show()
|
||||
}
|
||||
viewModel.readerSettingsProducer.observe(this) {
|
||||
viewBinding.infoBar.applyColorScheme(isBlackOnWhite = it.background.isLight(this))
|
||||
}
|
||||
viewModel.isZoomControlsEnabled.observe(this) {
|
||||
viewBinding.zoomControl.isVisible = it
|
||||
}
|
||||
addMenuProvider(ReaderMenuProvider(viewModel))
|
||||
}
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityReaderBinding.inflate(layoutInflater))
|
||||
readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings)
|
||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
|
||||
touchHelper = TapGridDispatcher(viewBinding.root, this)
|
||||
scrollTimer = scrollTimerFactory.create(resources, this, this)
|
||||
pageSaveHelper = pageSaveHelperFactory.create(this)
|
||||
controlDelegate = ReaderControlDelegate(resources, settings, tapGridSettings, this)
|
||||
viewBinding.zoomControl.listener = this
|
||||
viewBinding.actionsView.listener = this
|
||||
viewBinding.buttonTimer?.setOnClickListener(this)
|
||||
idlingDetector.bindToLifecycle(this)
|
||||
screenOrientationHelper.applySettings()
|
||||
viewModel.isBookmarkAdded.observe(this) { viewBinding.actionsView.isBookmarkAdded = it }
|
||||
scrollTimer.isActive.observe(this) {
|
||||
updateScrollTimerButton()
|
||||
viewBinding.actionsView.setTimerActive(it)
|
||||
}
|
||||
viewBinding.timerControl.onVisibilityChangeListener = this
|
||||
viewBinding.timerControl.attach(scrollTimer, this)
|
||||
if (resources.getBoolean(R.bool.is_tablet)) {
|
||||
viewBinding.timerControl.updateLayoutParams<CoordinatorLayout.LayoutParams> {
|
||||
topMargin = marginEnd + getThemeDimensionPixelOffset(appcompatR.attr.actionBarSize)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getParentActivityIntent(): Intent? {
|
||||
val manga = viewModel.getMangaOrNull() ?: return null
|
||||
return AppRouter.detailsIntent(this, manga)
|
||||
}
|
||||
viewModel.onLoadingError.observeEvent(
|
||||
this,
|
||||
DialogErrorObserver(
|
||||
host = viewBinding.container,
|
||||
fragment = null,
|
||||
resolver = exceptionResolver,
|
||||
onResolved = { isResolved ->
|
||||
if (isResolved) {
|
||||
viewModel.reload()
|
||||
} else if (viewModel.content.value.pages.isEmpty()) {
|
||||
dispatchNavigateUp()
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
viewModel.onError.observeEvent(
|
||||
this,
|
||||
SnackbarErrorObserver(
|
||||
host = viewBinding.container,
|
||||
fragment = null,
|
||||
resolver = exceptionResolver,
|
||||
onResolved = null,
|
||||
),
|
||||
)
|
||||
viewModel.readerMode.observe(this, Lifecycle.State.STARTED, this::onInitReader)
|
||||
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(viewBinding.container))
|
||||
viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged)
|
||||
combine(
|
||||
viewModel.isLoading,
|
||||
viewModel.content.map { it.pages.isNotEmpty() }.distinctUntilChanged(),
|
||||
::Pair,
|
||||
).flowOn(Dispatchers.Default)
|
||||
.observe(this, this::onLoadingStateChanged)
|
||||
viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn)
|
||||
viewModel.isInfoBarTransparent.observe(this) { viewBinding.infoBar.drawBackground = !it }
|
||||
viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged)
|
||||
viewModel.isBookmarkAdded.observe(this, MenuInvalidator(this))
|
||||
viewModel.onAskNsfwIncognito.observeEvent(this) { askForIncognitoMode() }
|
||||
viewModel.onShowToast.observeEvent(this) { msgId ->
|
||||
Snackbar.make(viewBinding.container, msgId, Snackbar.LENGTH_SHORT)
|
||||
.setAnchorView(viewBinding.toolbarDocked)
|
||||
.show()
|
||||
}
|
||||
viewModel.readerSettingsProducer.observe(this) {
|
||||
viewBinding.infoBar.applyColorScheme(isBlackOnWhite = it.background.isLight(this))
|
||||
}
|
||||
viewModel.isZoomControlsEnabled.observe(this) {
|
||||
viewBinding.zoomControl.isVisible = it
|
||||
}
|
||||
addMenuProvider(ReaderMenuProvider(viewModel))
|
||||
|
||||
override fun onUserInteraction() {
|
||||
super.onUserInteraction()
|
||||
if (!viewBinding.timerControl.isVisible) {
|
||||
scrollTimer.onUserInteraction()
|
||||
}
|
||||
idlingDetector.onUserInteraction()
|
||||
}
|
||||
observeWindowLayout()
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
viewModel.onPause()
|
||||
}
|
||||
// Apply initial double-mode considering foldable setting
|
||||
applyDoubleModeAuto()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
viewModel.onStop()
|
||||
}
|
||||
override fun getParentActivityIntent(): Intent? {
|
||||
val manga = viewModel.getMangaOrNull() ?: return null
|
||||
return AppRouter.detailsIntent(this, manga)
|
||||
}
|
||||
|
||||
override fun onProvideAssistContent(outContent: AssistContent) {
|
||||
super.onProvideAssistContent(outContent)
|
||||
viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it }
|
||||
}
|
||||
override fun onUserInteraction() {
|
||||
super.onUserInteraction()
|
||||
if (!viewBinding.timerControl.isVisible) {
|
||||
scrollTimer.onUserInteraction()
|
||||
}
|
||||
idlingDetector.onUserInteraction()
|
||||
}
|
||||
|
||||
override fun isNsfwContent(): Flow<Boolean> = viewModel.isMangaNsfw
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
viewModel.onPause()
|
||||
}
|
||||
|
||||
override fun onIdle() {
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
viewModel.onIdle()
|
||||
}
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
viewModel.onStop()
|
||||
}
|
||||
|
||||
override fun onVisibilityChanged(v: View, visibility: Int) {
|
||||
updateScrollTimerButton()
|
||||
}
|
||||
override fun onProvideAssistContent(outContent: AssistContent) {
|
||||
super.onProvideAssistContent(outContent)
|
||||
viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it }
|
||||
}
|
||||
|
||||
override fun onZoomIn() {
|
||||
readerManager.currentReader?.onZoomIn()
|
||||
}
|
||||
override fun isNsfwContent(): Flow<Boolean> = viewModel.isMangaNsfw
|
||||
|
||||
override fun onZoomOut() {
|
||||
readerManager.currentReader?.onZoomOut()
|
||||
}
|
||||
override fun onIdle() {
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
viewModel.onIdle()
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_timer -> onScrollTimerClick(isLongClick = false)
|
||||
}
|
||||
}
|
||||
override fun onVisibilityChanged(v: View, visibility: Int) {
|
||||
updateScrollTimerButton()
|
||||
}
|
||||
|
||||
private fun onInitReader(mode: ReaderMode?) {
|
||||
if (mode == null) {
|
||||
return
|
||||
}
|
||||
if (readerManager.currentMode != mode) {
|
||||
readerManager.replace(mode)
|
||||
}
|
||||
if (viewBinding.appbarTop.isVisible) {
|
||||
lifecycle.postDelayed(TimeUnit.SECONDS.toMillis(1), hideUiRunnable)
|
||||
}
|
||||
viewBinding.actionsView.setSliderReversed(mode == ReaderMode.REVERSED)
|
||||
viewBinding.timerControl.onReaderModeChanged(mode)
|
||||
}
|
||||
override fun onZoomIn() {
|
||||
readerManager.currentReader?.onZoomIn()
|
||||
}
|
||||
|
||||
private fun onLoadingStateChanged(value: Pair<Boolean, Boolean>) {
|
||||
val (isLoading, hasPages) = value
|
||||
val showLoadingLayout = isLoading && !hasPages
|
||||
if (viewBinding.layoutLoading.isVisible != showLoadingLayout) {
|
||||
val transition = Fade().addTarget(viewBinding.layoutLoading)
|
||||
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
|
||||
viewBinding.layoutLoading.isVisible = showLoadingLayout
|
||||
}
|
||||
if (isLoading && hasPages) {
|
||||
viewBinding.toastView.show(R.string.loading_)
|
||||
} else {
|
||||
viewBinding.toastView.hide()
|
||||
}
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
override fun onZoomOut() {
|
||||
readerManager.currentReader?.onZoomOut()
|
||||
}
|
||||
|
||||
override fun onGridTouch(area: TapGridArea): Boolean {
|
||||
return isReaderResumed() && controlDelegate.onGridTouch(area)
|
||||
}
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_timer -> onScrollTimerClick(isLongClick = false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGridLongTouch(area: TapGridArea) {
|
||||
if (isReaderResumed()) {
|
||||
controlDelegate.onGridLongTouch(area)
|
||||
}
|
||||
}
|
||||
private fun onInitReader(mode: ReaderMode?) {
|
||||
if (mode == null) {
|
||||
return
|
||||
}
|
||||
if (readerManager.currentMode != mode) {
|
||||
readerManager.replace(mode)
|
||||
}
|
||||
if (viewBinding.appbarTop.isVisible) {
|
||||
lifecycle.postDelayed(TimeUnit.SECONDS.toMillis(1), hideUiRunnable)
|
||||
}
|
||||
viewBinding.actionsView.setSliderReversed(mode == ReaderMode.REVERSED)
|
||||
viewBinding.timerControl.onReaderModeChanged(mode)
|
||||
}
|
||||
|
||||
override fun onProcessTouch(rawX: Int, rawY: Int): Boolean {
|
||||
return if (
|
||||
rawX <= gestureInsets.left ||
|
||||
rawY <= gestureInsets.top ||
|
||||
rawX >= viewBinding.root.width - gestureInsets.right ||
|
||||
rawY >= viewBinding.root.height - gestureInsets.bottom ||
|
||||
viewBinding.appbarTop.hasGlobalPoint(rawX, rawY) ||
|
||||
viewBinding.toolbarDocked?.hasGlobalPoint(rawX, rawY) == true
|
||||
) {
|
||||
false
|
||||
} else {
|
||||
val touchables = window.peekDecorView()?.touchables
|
||||
touchables?.none { it.hasGlobalPoint(rawX, rawY) } != false
|
||||
}
|
||||
}
|
||||
private fun onLoadingStateChanged(value: Pair<Boolean, Boolean>) {
|
||||
val (isLoading, hasPages) = value
|
||||
val showLoadingLayout = isLoading && !hasPages
|
||||
if (viewBinding.layoutLoading.isVisible != showLoadingLayout) {
|
||||
val transition = Fade().addTarget(viewBinding.layoutLoading)
|
||||
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
|
||||
viewBinding.layoutLoading.isVisible = showLoadingLayout
|
||||
}
|
||||
if (isLoading && hasPages) {
|
||||
viewBinding.toastView.show(R.string.loading_)
|
||||
} else {
|
||||
viewBinding.toastView.hide()
|
||||
}
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
||||
touchHelper.dispatchTouchEvent(ev)
|
||||
if (!viewBinding.timerControl.hasGlobalPoint(ev.rawX.toInt(), ev.rawY.toInt())) {
|
||||
scrollTimer.onTouchEvent(ev)
|
||||
}
|
||||
return super.dispatchTouchEvent(ev)
|
||||
}
|
||||
override fun onGridTouch(area: TapGridArea): Boolean {
|
||||
return isReaderResumed() && controlDelegate.onGridTouch(area)
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
return controlDelegate.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event)
|
||||
}
|
||||
override fun onGridLongTouch(area: TapGridArea) {
|
||||
if (isReaderResumed()) {
|
||||
controlDelegate.onGridLongTouch(area)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
return controlDelegate.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event)
|
||||
}
|
||||
override fun onProcessTouch(rawX: Int, rawY: Int): Boolean {
|
||||
return if (
|
||||
rawX <= gestureInsets.left ||
|
||||
rawY <= gestureInsets.top ||
|
||||
rawX >= viewBinding.root.width - gestureInsets.right ||
|
||||
rawY >= viewBinding.root.height - gestureInsets.bottom ||
|
||||
viewBinding.appbarTop.hasGlobalPoint(rawX, rawY) ||
|
||||
viewBinding.toolbarDocked?.hasGlobalPoint(rawX, rawY) == true
|
||||
) {
|
||||
false
|
||||
} else {
|
||||
val touchables = window.peekDecorView()?.touchables
|
||||
touchables?.none { it.hasGlobalPoint(rawX, rawY) } != false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChapterSelected(chapter: MangaChapter): Boolean {
|
||||
viewModel.switchChapter(chapter.id, 0)
|
||||
return true
|
||||
}
|
||||
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
||||
touchHelper.dispatchTouchEvent(ev)
|
||||
if (!viewBinding.timerControl.hasGlobalPoint(ev.rawX.toInt(), ev.rawY.toInt())) {
|
||||
scrollTimer.onTouchEvent(ev)
|
||||
}
|
||||
return super.dispatchTouchEvent(ev)
|
||||
}
|
||||
|
||||
override fun onPageSelected(page: ReaderPage): Boolean {
|
||||
lifecycleScope.launch(Dispatchers.Default) {
|
||||
val pages = viewModel.content.value.pages
|
||||
val index = pages.indexOfFirst { it.chapterId == page.chapterId && it.id == page.id }
|
||||
if (index != -1) {
|
||||
withContext(Dispatchers.Main) {
|
||||
readerManager.currentReader?.switchPageTo(index, true)
|
||||
}
|
||||
} else {
|
||||
viewModel.switchChapter(page.chapterId, page.index)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
return controlDelegate.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
override fun onReaderModeChanged(mode: ReaderMode) {
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
viewModel.switchMode(mode)
|
||||
viewBinding.timerControl.onReaderModeChanged(mode)
|
||||
}
|
||||
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
return controlDelegate.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event)
|
||||
}
|
||||
|
||||
override fun onDoubleModeChanged(isEnabled: Boolean) {
|
||||
readerManager.setDoubleReaderMode(isEnabled)
|
||||
}
|
||||
override fun onChapterSelected(chapter: MangaChapter): Boolean {
|
||||
viewModel.switchChapter(chapter.id, 0)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun setKeepScreenOn(isKeep: Boolean) {
|
||||
if (isKeep) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
override fun onPageSelected(page: ReaderPage): Boolean {
|
||||
lifecycleScope.launch(Dispatchers.Default) {
|
||||
val pages = viewModel.content.value.pages
|
||||
val index = pages.indexOfFirst { it.chapterId == page.chapterId && it.id == page.id }
|
||||
if (index != -1) {
|
||||
withContext(Dispatchers.Main) {
|
||||
readerManager.currentReader?.switchPageTo(index, true)
|
||||
}
|
||||
} else {
|
||||
viewModel.switchChapter(page.chapterId, page.index)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun setUiIsVisible(isUiVisible: Boolean) {
|
||||
if (viewBinding.appbarTop.isVisible != isUiVisible) {
|
||||
if (isAnimationsEnabled) {
|
||||
val transition = TransitionSet()
|
||||
.setOrdering(TransitionSet.ORDERING_TOGETHER)
|
||||
.addTransition(Slide(Gravity.TOP).addTarget(viewBinding.appbarTop))
|
||||
.addTransition(Fade().addTarget(viewBinding.infoBar))
|
||||
viewBinding.toolbarDocked?.let {
|
||||
transition.addTransition(Slide(Gravity.BOTTOM).addTarget(it))
|
||||
}
|
||||
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
|
||||
}
|
||||
val isFullscreen = settings.isReaderFullscreenEnabled
|
||||
viewBinding.appbarTop.isVisible = isUiVisible
|
||||
viewBinding.toolbarDocked?.isVisible = isUiVisible
|
||||
viewBinding.infoBar.isGone = isUiVisible || (!viewModel.isInfoBarEnabled.value)
|
||||
viewBinding.infoBar.isTimeVisible = isFullscreen
|
||||
updateScrollTimerButton()
|
||||
systemUiController.setSystemUiVisible(isUiVisible || !isFullscreen)
|
||||
viewBinding.root.requestApplyInsets()
|
||||
}
|
||||
}
|
||||
override fun onReaderModeChanged(mode: ReaderMode) {
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
viewModel.switchMode(mode)
|
||||
viewBinding.timerControl.onReaderModeChanged(mode)
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
gestureInsets = insets.getInsets(WindowInsetsCompat.Type.systemGestures())
|
||||
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
viewBinding.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = systemBars.top
|
||||
rightMargin = systemBars.right
|
||||
leftMargin = systemBars.left
|
||||
}
|
||||
if (viewBinding.toolbarDocked != null) {
|
||||
viewBinding.actionsView.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
bottomMargin = systemBars.bottom
|
||||
rightMargin = systemBars.right
|
||||
leftMargin = systemBars.left
|
||||
}
|
||||
}
|
||||
viewBinding.infoBar.updatePadding(
|
||||
top = systemBars.top,
|
||||
)
|
||||
val innerInsets = Insets.of(
|
||||
systemBars.left,
|
||||
if (viewBinding.appbarTop.isVisible) viewBinding.appbarTop.height else systemBars.top,
|
||||
systemBars.right,
|
||||
viewBinding.toolbarDocked?.takeIf { it.isVisible }?.height ?: systemBars.bottom,
|
||||
)
|
||||
return WindowInsetsCompat.Builder(insets)
|
||||
.setInsets(WindowInsetsCompat.Type.systemBars(), innerInsets)
|
||||
.build()
|
||||
}
|
||||
override fun onDoubleModeChanged(isEnabled: Boolean) {
|
||||
// Combine manual toggle with foldable auto setting
|
||||
applyDoubleModeAuto(isEnabled)
|
||||
}
|
||||
|
||||
override fun switchPageBy(delta: Int) {
|
||||
readerManager.currentReader?.switchPageBy(delta)
|
||||
}
|
||||
private fun applyDoubleModeAuto(manualEnabled: Boolean? = null) {
|
||||
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
// Auto double-page on foldable when device is unfolded (half-opened or flat)
|
||||
val autoFoldable = settings.isReaderDoubleOnFoldable && isFoldUnfolded
|
||||
val manualLandscape = (manualEnabled ?: settings.isReaderDoubleOnLandscape) && isLandscape
|
||||
val autoEnabled = autoFoldable || manualLandscape
|
||||
readerManager.setDoubleReaderMode(autoEnabled)
|
||||
}
|
||||
|
||||
override fun switchChapterBy(delta: Int) {
|
||||
viewModel.switchChapterBy(delta)
|
||||
}
|
||||
private fun setKeepScreenOn(isKeep: Boolean) {
|
||||
if (isKeep) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
|
||||
override fun openMenu() {
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
val currentMode = readerManager.currentMode ?: return
|
||||
router.showReaderConfigSheet(currentMode)
|
||||
}
|
||||
private fun setUiIsVisible(isUiVisible: Boolean) {
|
||||
if (viewBinding.appbarTop.isVisible != isUiVisible) {
|
||||
if (isAnimationsEnabled) {
|
||||
val transition = TransitionSet()
|
||||
.setOrdering(TransitionSet.ORDERING_TOGETHER)
|
||||
.addTransition(Slide(Gravity.TOP).addTarget(viewBinding.appbarTop))
|
||||
.addTransition(Fade().addTarget(viewBinding.infoBar))
|
||||
viewBinding.toolbarDocked?.let {
|
||||
transition.addTransition(Slide(Gravity.BOTTOM).addTarget(it))
|
||||
}
|
||||
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
|
||||
}
|
||||
val isFullscreen = settings.isReaderFullscreenEnabled
|
||||
viewBinding.appbarTop.isVisible = isUiVisible
|
||||
viewBinding.toolbarDocked?.isVisible = isUiVisible
|
||||
viewBinding.infoBar.isGone = isUiVisible || (!viewModel.isInfoBarEnabled.value)
|
||||
viewBinding.infoBar.isTimeVisible = isFullscreen
|
||||
updateScrollTimerButton()
|
||||
systemUiController.setSystemUiVisible(isUiVisible || !isFullscreen)
|
||||
viewBinding.root.requestApplyInsets()
|
||||
}
|
||||
}
|
||||
|
||||
override fun scrollBy(delta: Int, smooth: Boolean): Boolean {
|
||||
return readerManager.currentReader?.scrollBy(delta, smooth) == true
|
||||
}
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
gestureInsets = insets.getInsets(WindowInsetsCompat.Type.systemGestures())
|
||||
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
viewBinding.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = systemBars.top
|
||||
rightMargin = systemBars.right
|
||||
leftMargin = systemBars.left
|
||||
}
|
||||
if (viewBinding.toolbarDocked != null) {
|
||||
viewBinding.actionsView.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
bottomMargin = systemBars.bottom
|
||||
rightMargin = systemBars.right
|
||||
leftMargin = systemBars.left
|
||||
}
|
||||
}
|
||||
viewBinding.infoBar.updatePadding(
|
||||
top = systemBars.top,
|
||||
)
|
||||
val innerInsets = Insets.of(
|
||||
systemBars.left,
|
||||
if (viewBinding.appbarTop.isVisible) viewBinding.appbarTop.height else systemBars.top,
|
||||
systemBars.right,
|
||||
viewBinding.toolbarDocked?.takeIf { it.isVisible }?.height ?: systemBars.bottom,
|
||||
)
|
||||
return WindowInsetsCompat.Builder(insets)
|
||||
.setInsets(WindowInsetsCompat.Type.systemBars(), innerInsets)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun toggleUiVisibility() {
|
||||
setUiIsVisible(!viewBinding.appbarTop.isVisible)
|
||||
}
|
||||
override fun switchPageBy(delta: Int) {
|
||||
readerManager.currentReader?.switchPageBy(delta)
|
||||
}
|
||||
|
||||
override fun isReaderResumed(): Boolean {
|
||||
val reader = readerManager.currentReader ?: return false
|
||||
return reader.isResumed && supportFragmentManager.fragments.lastOrNull() === reader
|
||||
}
|
||||
override fun switchChapterBy(delta: Int) {
|
||||
viewModel.switchChapterBy(delta)
|
||||
}
|
||||
|
||||
override fun onBookmarkClick() {
|
||||
viewModel.toggleBookmark()
|
||||
}
|
||||
override fun openMenu() {
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
val currentMode = readerManager.currentMode ?: return
|
||||
router.showReaderConfigSheet(currentMode)
|
||||
}
|
||||
|
||||
override fun onSavePageClick() {
|
||||
viewModel.saveCurrentPage(pageSaveHelper)
|
||||
}
|
||||
override fun scrollBy(delta: Int, smooth: Boolean): Boolean {
|
||||
return readerManager.currentReader?.scrollBy(delta, smooth) == true
|
||||
}
|
||||
|
||||
override fun onScrollTimerClick(isLongClick: Boolean) {
|
||||
if (isLongClick) {
|
||||
scrollTimer.setActive(!scrollTimer.isActive.value)
|
||||
} else {
|
||||
viewBinding.timerControl.showOrHide()
|
||||
}
|
||||
}
|
||||
override fun toggleUiVisibility() {
|
||||
setUiIsVisible(!viewBinding.appbarTop.isVisible)
|
||||
}
|
||||
|
||||
override fun toggleScreenOrientation() {
|
||||
if (screenOrientationHelper.toggleScreenOrientation()) {
|
||||
Snackbar.make(
|
||||
viewBinding.container,
|
||||
if (screenOrientationHelper.isLocked) {
|
||||
R.string.screen_rotation_locked
|
||||
} else {
|
||||
R.string.screen_rotation_unlocked
|
||||
},
|
||||
Snackbar.LENGTH_SHORT,
|
||||
).setAnchorView(viewBinding.toolbarDocked)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
override fun isReaderResumed(): Boolean {
|
||||
val reader = readerManager.currentReader ?: return false
|
||||
return reader.isResumed && supportFragmentManager.fragments.lastOrNull() === reader
|
||||
}
|
||||
|
||||
override fun switchPageTo(index: Int) {
|
||||
val pages = viewModel.getCurrentChapterPages()
|
||||
val page = pages?.getOrNull(index) ?: return
|
||||
val chapterId = viewModel.getCurrentState()?.chapterId ?: return
|
||||
onPageSelected(ReaderPage(page, index, chapterId))
|
||||
}
|
||||
override fun onBookmarkClick() {
|
||||
viewModel.toggleBookmark()
|
||||
}
|
||||
|
||||
private fun onReaderBarChanged(isBarEnabled: Boolean) {
|
||||
viewBinding.infoBar.isVisible = isBarEnabled && viewBinding.appbarTop.isGone
|
||||
}
|
||||
override fun onSavePageClick() {
|
||||
viewModel.saveCurrentPage(pageSaveHelper)
|
||||
}
|
||||
|
||||
private fun onUiStateChanged(pair: Pair<ReaderUiState?, ReaderUiState?>) {
|
||||
val (previous: ReaderUiState?, uiState: ReaderUiState?) = pair
|
||||
title = uiState?.mangaName ?: getString(R.string.loading_)
|
||||
viewBinding.infoBar.update(uiState)
|
||||
if (uiState == null) {
|
||||
supportActionBar?.subtitle = null
|
||||
viewBinding.actionsView.setSliderValue(0, 1)
|
||||
viewBinding.actionsView.isSliderEnabled = false
|
||||
return
|
||||
}
|
||||
val chapterTitle = uiState.getChapterTitle(resources)
|
||||
supportActionBar?.subtitle = when {
|
||||
uiState.incognito -> getString(R.string.incognito_mode)
|
||||
else -> chapterTitle
|
||||
}
|
||||
if (chapterTitle != previous?.getChapterTitle(resources) && chapterTitle.isNotEmpty()) {
|
||||
viewBinding.toastView.showTemporary(chapterTitle, TOAST_DURATION)
|
||||
}
|
||||
if (uiState.isSliderAvailable()) {
|
||||
viewBinding.actionsView.setSliderValue(
|
||||
value = uiState.currentPage,
|
||||
max = uiState.totalPages - 1,
|
||||
)
|
||||
} else {
|
||||
viewBinding.actionsView.setSliderValue(0, 1)
|
||||
}
|
||||
viewBinding.actionsView.isSliderEnabled = uiState.isSliderAvailable()
|
||||
viewBinding.actionsView.isNextEnabled = uiState.hasNextChapter()
|
||||
viewBinding.actionsView.isPrevEnabled = uiState.hasPreviousChapter()
|
||||
}
|
||||
override fun onScrollTimerClick(isLongClick: Boolean) {
|
||||
if (isLongClick) {
|
||||
scrollTimer.setActive(!scrollTimer.isActive.value)
|
||||
} else {
|
||||
viewBinding.timerControl.showOrHide()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateScrollTimerButton() {
|
||||
val button = viewBinding.buttonTimer ?: return
|
||||
val isButtonVisible = scrollTimer.isActive.value
|
||||
&& settings.isReaderAutoscrollFabVisible
|
||||
&& !viewBinding.appbarTop.isVisible
|
||||
&& !viewBinding.timerControl.isVisible
|
||||
if (button.isVisible != isButtonVisible) {
|
||||
val transition = Fade().addTarget(button)
|
||||
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
|
||||
button.isVisible = isButtonVisible
|
||||
}
|
||||
}
|
||||
override fun toggleScreenOrientation() {
|
||||
if (screenOrientationHelper.toggleScreenOrientation()) {
|
||||
Snackbar.make(
|
||||
viewBinding.container,
|
||||
if (screenOrientationHelper.isLocked) {
|
||||
R.string.screen_rotation_locked
|
||||
} else {
|
||||
R.string.screen_rotation_unlocked
|
||||
},
|
||||
Snackbar.LENGTH_SHORT,
|
||||
).setAnchorView(viewBinding.toolbarDocked)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun askForIncognitoMode() {
|
||||
buildAlertDialog(this, isCentered = true) {
|
||||
var dontAskAgain = false
|
||||
val listener = DialogInterface.OnClickListener { _, which ->
|
||||
if (which == DialogInterface.BUTTON_NEUTRAL) {
|
||||
finishAfterTransition()
|
||||
} else {
|
||||
viewModel.setIncognitoMode(which == DialogInterface.BUTTON_POSITIVE, dontAskAgain)
|
||||
}
|
||||
}
|
||||
setCheckbox(R.string.dont_ask_again, dontAskAgain) { _, isChecked ->
|
||||
dontAskAgain = isChecked
|
||||
}
|
||||
setIcon(R.drawable.ic_incognito)
|
||||
setTitle(R.string.incognito_mode)
|
||||
setMessage(R.string.incognito_mode_hint_nsfw)
|
||||
setPositiveButton(R.string.incognito, listener)
|
||||
setNegativeButton(R.string.disable, listener)
|
||||
setNeutralButton(android.R.string.cancel, listener)
|
||||
setOnCancelListener { finishAfterTransition() }
|
||||
setCancelable(true)
|
||||
}.show()
|
||||
}
|
||||
override fun switchPageTo(index: Int) {
|
||||
val pages = viewModel.getCurrentChapterPages()
|
||||
val page = pages?.getOrNull(index) ?: return
|
||||
val chapterId = viewModel.getCurrentState()?.chapterId ?: return
|
||||
onPageSelected(ReaderPage(page, index, chapterId))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun onReaderBarChanged(isBarEnabled: Boolean) {
|
||||
viewBinding.infoBar.isVisible = isBarEnabled && viewBinding.appbarTop.isGone
|
||||
}
|
||||
|
||||
private const val TOAST_DURATION = 2000L
|
||||
}
|
||||
private fun onUiStateChanged(pair: Pair<ReaderUiState?, ReaderUiState?>) {
|
||||
val (previous: ReaderUiState?, uiState: ReaderUiState?) = pair
|
||||
title = uiState?.mangaName ?: getString(R.string.loading_)
|
||||
viewBinding.infoBar.update(uiState)
|
||||
if (uiState == null) {
|
||||
supportActionBar?.subtitle = null
|
||||
viewBinding.actionsView.setSliderValue(0, 1)
|
||||
viewBinding.actionsView.isSliderEnabled = false
|
||||
return
|
||||
}
|
||||
val chapterTitle = uiState.getChapterTitle(resources)
|
||||
supportActionBar?.subtitle = when {
|
||||
uiState.incognito -> getString(R.string.incognito_mode)
|
||||
else -> chapterTitle
|
||||
}
|
||||
if (
|
||||
settings.isReaderChapterToastEnabled &&
|
||||
chapterTitle != previous?.getChapterTitle(resources) &&
|
||||
chapterTitle.isNotEmpty()
|
||||
) {
|
||||
viewBinding.toastView.showTemporary(chapterTitle, TOAST_DURATION)
|
||||
}
|
||||
if (uiState.isSliderAvailable()) {
|
||||
viewBinding.actionsView.setSliderValue(
|
||||
value = uiState.currentPage,
|
||||
max = uiState.totalPages - 1,
|
||||
)
|
||||
} else {
|
||||
viewBinding.actionsView.setSliderValue(0, 1)
|
||||
}
|
||||
viewBinding.actionsView.isSliderEnabled = uiState.isSliderAvailable()
|
||||
viewBinding.actionsView.isNextEnabled = uiState.hasNextChapter()
|
||||
viewBinding.actionsView.isPrevEnabled = uiState.hasPreviousChapter()
|
||||
}
|
||||
|
||||
private fun updateScrollTimerButton() {
|
||||
val button = viewBinding.buttonTimer ?: return
|
||||
val isButtonVisible = scrollTimer.isActive.value
|
||||
&& settings.isReaderAutoscrollFabVisible
|
||||
&& !viewBinding.appbarTop.isVisible
|
||||
&& !viewBinding.timerControl.isVisible
|
||||
if (button.isVisible != isButtonVisible) {
|
||||
val transition = Fade().addTarget(button)
|
||||
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
|
||||
button.isVisible = isButtonVisible
|
||||
}
|
||||
}
|
||||
|
||||
// Observe foldable window layout to auto-enable double-page if configured
|
||||
private fun observeWindowLayout() {
|
||||
WindowInfoTracker.getOrCreate(this)
|
||||
.windowLayoutInfo(this)
|
||||
.onEach { info ->
|
||||
val fold = info.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()
|
||||
val unfolded = when (fold?.state) {
|
||||
FoldingFeature.State.HALF_OPENED, FoldingFeature.State.FLAT -> true
|
||||
else -> false
|
||||
}
|
||||
if (unfolded != isFoldUnfolded) {
|
||||
isFoldUnfolded = unfolded
|
||||
applyDoubleModeAuto()
|
||||
}
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
private fun askForIncognitoMode() {
|
||||
buildAlertDialog(this, isCentered = true) {
|
||||
var dontAskAgain = false
|
||||
val listener = DialogInterface.OnClickListener { _, which ->
|
||||
if (which == DialogInterface.BUTTON_NEUTRAL) {
|
||||
finishAfterTransition()
|
||||
} else {
|
||||
viewModel.setIncognitoMode(which == DialogInterface.BUTTON_POSITIVE, dontAskAgain)
|
||||
}
|
||||
}
|
||||
setCheckbox(R.string.dont_ask_again, dontAskAgain) { _, isChecked ->
|
||||
dontAskAgain = isChecked
|
||||
}
|
||||
setIcon(R.drawable.ic_incognito)
|
||||
setTitle(R.string.incognito_mode)
|
||||
setMessage(R.string.incognito_mode_hint_nsfw)
|
||||
setPositiveButton(R.string.incognito, listener)
|
||||
setNegativeButton(R.string.disable, listener)
|
||||
setNeutralButton(android.R.string.cancel, listener)
|
||||
setOnCancelListener { finishAfterTransition() }
|
||||
setCancelable(true)
|
||||
}.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TOAST_DURATION = 2000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ class ReaderManager(
|
||||
fun setDoubleReaderMode(isEnabled: Boolean) {
|
||||
val mode = currentMode
|
||||
val prevReader = currentReader?.javaClass
|
||||
invalidateTypesMap(isEnabled && isLandscape())
|
||||
invalidateTypesMap(isEnabled)
|
||||
val newReader = modeMap[mode]
|
||||
if (mode != null && newReader != prevReader) {
|
||||
replace(mode)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,9 @@ import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.transition.TransitionManager
|
||||
import com.google.android.material.button.MaterialButtonToggleGroup
|
||||
import com.google.android.material.slider.Slider
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
@@ -25,7 +27,9 @@ import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||
import org.koitharu.kotatsu.core.util.ext.consume
|
||||
import org.koitharu.kotatsu.core.util.ext.findParentCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.setValueRounded
|
||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter
|
||||
import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||
@@ -34,214 +38,244 @@ import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ReaderConfigSheet :
|
||||
BaseAdaptiveSheet<SheetReaderConfigBinding>(),
|
||||
View.OnClickListener,
|
||||
MaterialButtonToggleGroup.OnButtonCheckedListener,
|
||||
CompoundButton.OnCheckedChangeListener {
|
||||
BaseAdaptiveSheet<SheetReaderConfigBinding>(),
|
||||
View.OnClickListener,
|
||||
MaterialButtonToggleGroup.OnButtonCheckedListener,
|
||||
CompoundButton.OnCheckedChangeListener,
|
||||
Slider.OnChangeListener {
|
||||
|
||||
private val viewModel by activityViewModels<ReaderViewModel>()
|
||||
private val viewModel by activityViewModels<ReaderViewModel>()
|
||||
|
||||
@Inject
|
||||
lateinit var orientationHelper: ScreenOrientationHelper
|
||||
@Inject
|
||||
lateinit var orientationHelper: ScreenOrientationHelper
|
||||
|
||||
@Inject
|
||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||
@Inject
|
||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var pageLoader: PageLoader
|
||||
@Inject
|
||||
lateinit var pageLoader: PageLoader
|
||||
|
||||
private lateinit var mode: ReaderMode
|
||||
private lateinit var imageServerDelegate: ImageServerDelegate
|
||||
private lateinit var mode: ReaderMode
|
||||
private lateinit var imageServerDelegate: ImageServerDelegate
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
mode = arguments?.getInt(AppRouter.KEY_READER_MODE)
|
||||
?.let { ReaderMode.valueOf(it) }
|
||||
?: ReaderMode.STANDARD
|
||||
imageServerDelegate = ImageServerDelegate(
|
||||
mangaRepositoryFactory = mangaRepositoryFactory,
|
||||
mangaSource = viewModel.getMangaOrNull()?.source,
|
||||
)
|
||||
}
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
mode = arguments?.getInt(AppRouter.KEY_READER_MODE)
|
||||
?.let { ReaderMode.valueOf(it) }
|
||||
?: ReaderMode.STANDARD
|
||||
imageServerDelegate = ImageServerDelegate(
|
||||
mangaRepositoryFactory = mangaRepositoryFactory,
|
||||
mangaSource = viewModel.getMangaOrNull()?.source,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
): SheetReaderConfigBinding {
|
||||
return SheetReaderConfigBinding.inflate(inflater, container, false)
|
||||
}
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
): SheetReaderConfigBinding {
|
||||
return SheetReaderConfigBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(
|
||||
binding: SheetReaderConfigBinding,
|
||||
savedInstanceState: Bundle?,
|
||||
) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
observeScreenOrientation()
|
||||
binding.buttonStandard.isChecked = mode == ReaderMode.STANDARD
|
||||
binding.buttonReversed.isChecked = mode == ReaderMode.REVERSED
|
||||
binding.buttonWebtoon.isChecked = mode == ReaderMode.WEBTOON
|
||||
binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL
|
||||
binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape
|
||||
binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED
|
||||
binding.switchPullGesture.isChecked = settings.isWebtoonPullGestureEnabled
|
||||
binding.switchPullGesture.isEnabled = mode == ReaderMode.WEBTOON
|
||||
override fun onViewBindingCreated(
|
||||
binding: SheetReaderConfigBinding,
|
||||
savedInstanceState: Bundle?,
|
||||
) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
observeScreenOrientation()
|
||||
binding.buttonStandard.isChecked = mode == ReaderMode.STANDARD
|
||||
binding.buttonReversed.isChecked = mode == ReaderMode.REVERSED
|
||||
binding.buttonWebtoon.isChecked = mode == ReaderMode.WEBTOON
|
||||
binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL
|
||||
binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape
|
||||
binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED
|
||||
binding.switchDoubleFoldable.isChecked = settings.isReaderDoubleOnFoldable
|
||||
binding.switchDoubleFoldable.isEnabled = binding.switchDoubleReader.isEnabled
|
||||
binding.sliderDoubleSensitivity.setValueRounded(settings.readerDoublePagesSensitivity * 100f)
|
||||
binding.sliderDoubleSensitivity.setLabelFormatter(IntPercentLabelFormatter(binding.root.context))
|
||||
binding.adjustSensitivitySlider(withAnimation = false)
|
||||
|
||||
binding.checkableGroup.addOnButtonCheckedListener(this)
|
||||
binding.buttonSavePage.setOnClickListener(this)
|
||||
binding.buttonScreenRotate.setOnClickListener(this)
|
||||
binding.buttonSettings.setOnClickListener(this)
|
||||
binding.buttonImageServer.setOnClickListener(this)
|
||||
binding.buttonColorFilter.setOnClickListener(this)
|
||||
binding.buttonScrollTimer.setOnClickListener(this)
|
||||
binding.buttonBookmark.setOnClickListener(this)
|
||||
binding.switchDoubleReader.setOnCheckedChangeListener(this)
|
||||
binding.switchPullGesture.setOnCheckedChangeListener(this)
|
||||
binding.checkableGroup.addOnButtonCheckedListener(this)
|
||||
binding.buttonSavePage.setOnClickListener(this)
|
||||
binding.buttonScreenRotate.setOnClickListener(this)
|
||||
binding.buttonSettings.setOnClickListener(this)
|
||||
binding.buttonImageServer.setOnClickListener(this)
|
||||
binding.buttonColorFilter.setOnClickListener(this)
|
||||
binding.buttonScrollTimer.setOnClickListener(this)
|
||||
binding.buttonBookmark.setOnClickListener(this)
|
||||
binding.switchDoubleReader.setOnCheckedChangeListener(this)
|
||||
binding.switchDoubleFoldable.setOnCheckedChangeListener(this)
|
||||
binding.sliderDoubleSensitivity.addOnChangeListener(this)
|
||||
|
||||
viewModel.isBookmarkAdded.observe(viewLifecycleOwner) {
|
||||
binding.buttonBookmark.setText(if (it) R.string.bookmark_remove else R.string.bookmark_add)
|
||||
binding.buttonBookmark.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
if (it) R.drawable.ic_bookmark_checked else R.drawable.ic_bookmark, 0, 0, 0,
|
||||
)
|
||||
}
|
||||
viewModel.isBookmarkAdded.observe(viewLifecycleOwner) {
|
||||
binding.buttonBookmark.setText(if (it) R.string.bookmark_remove else R.string.bookmark_add)
|
||||
binding.buttonBookmark.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
if (it) R.drawable.ic_bookmark_checked else R.drawable.ic_bookmark, 0, 0, 0,
|
||||
)
|
||||
}
|
||||
|
||||
viewLifecycleScope.launch {
|
||||
val isAvailable = imageServerDelegate.isAvailable()
|
||||
if (isAvailable) {
|
||||
bindImageServerTitle()
|
||||
}
|
||||
binding.buttonImageServer.isVisible = isAvailable
|
||||
}
|
||||
}
|
||||
viewLifecycleScope.launch {
|
||||
val isAvailable = imageServerDelegate.isAvailable()
|
||||
if (isAvailable) {
|
||||
bindImageServerTitle()
|
||||
}
|
||||
binding.buttonImageServer.isVisible = isAvailable
|
||||
}
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val typeMask = WindowInsetsCompat.Type.systemBars()
|
||||
viewBinding?.scrollView?.updatePadding(
|
||||
bottom = insets.getInsets(typeMask).bottom,
|
||||
)
|
||||
return insets.consume(v, typeMask, bottom = true)
|
||||
}
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val typeMask = WindowInsetsCompat.Type.systemBars()
|
||||
viewBinding?.scrollView?.updatePadding(
|
||||
bottom = insets.getInsets(typeMask).bottom,
|
||||
)
|
||||
return insets.consume(v, typeMask, bottom = true)
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_settings -> {
|
||||
router.openReaderSettings()
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_settings -> {
|
||||
router.openReaderSettings()
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
R.id.button_scroll_timer -> {
|
||||
findParentCallback(Callback::class.java)?.onScrollTimerClick(false) ?: return
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
R.id.button_scroll_timer -> {
|
||||
findParentCallback(Callback::class.java)?.onScrollTimerClick(false) ?: return
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
R.id.button_save_page -> {
|
||||
findParentCallback(Callback::class.java)?.onSavePageClick() ?: return
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
R.id.button_save_page -> {
|
||||
findParentCallback(Callback::class.java)?.onSavePageClick() ?: return
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
R.id.button_screen_rotate -> {
|
||||
orientationHelper.isLandscape = !orientationHelper.isLandscape
|
||||
}
|
||||
R.id.button_screen_rotate -> {
|
||||
orientationHelper.isLandscape = !orientationHelper.isLandscape
|
||||
}
|
||||
|
||||
R.id.button_bookmark -> {
|
||||
viewModel.toggleBookmark()
|
||||
}
|
||||
R.id.button_bookmark -> {
|
||||
viewModel.toggleBookmark()
|
||||
}
|
||||
|
||||
R.id.button_color_filter -> {
|
||||
val page = viewModel.getCurrentPage() ?: return
|
||||
val manga = viewModel.getMangaOrNull() ?: return
|
||||
router.openColorFilterConfig(manga, page)
|
||||
}
|
||||
R.id.button_color_filter -> {
|
||||
val page = viewModel.getCurrentPage() ?: return
|
||||
val manga = viewModel.getMangaOrNull() ?: return
|
||||
router.openColorFilterConfig(manga, page)
|
||||
}
|
||||
|
||||
R.id.button_image_server -> viewLifecycleScope.launch {
|
||||
if (imageServerDelegate.showDialog(v.context)) {
|
||||
bindImageServerTitle()
|
||||
pageLoader.invalidate(clearCache = true)
|
||||
viewModel.switchChapterBy(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
R.id.button_image_server -> viewLifecycleScope.launch {
|
||||
if (imageServerDelegate.showDialog(v.context)) {
|
||||
bindImageServerTitle()
|
||||
pageLoader.invalidate(clearCache = true)
|
||||
viewModel.switchChapterBy(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
|
||||
when (buttonView.id) {
|
||||
R.id.switch_screen_lock_rotation -> {
|
||||
orientationHelper.isLocked = isChecked
|
||||
}
|
||||
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
|
||||
when (buttonView.id) {
|
||||
R.id.switch_screen_lock_rotation -> {
|
||||
orientationHelper.isLocked = isChecked
|
||||
}
|
||||
|
||||
R.id.switch_double_reader -> {
|
||||
settings.isReaderDoubleOnLandscape = isChecked
|
||||
findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked)
|
||||
}
|
||||
R.id.switch_double_reader -> {
|
||||
settings.isReaderDoubleOnLandscape = isChecked
|
||||
viewBinding?.adjustSensitivitySlider(withAnimation = true)
|
||||
findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked)
|
||||
}
|
||||
|
||||
R.id.switch_pull_gesture -> {
|
||||
settings.isWebtoonPullGestureEnabled = isChecked
|
||||
}
|
||||
}
|
||||
}
|
||||
R.id.switch_double_foldable -> {
|
||||
settings.isReaderDoubleOnFoldable = isChecked
|
||||
// Re-evaluate double-page considering foldable state and current manual toggle
|
||||
findParentCallback(Callback::class.java)?.onDoubleModeChanged(settings.isReaderDoubleOnLandscape)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onButtonChecked(
|
||||
group: MaterialButtonToggleGroup?,
|
||||
checkedId: Int,
|
||||
isChecked: Boolean,
|
||||
) {
|
||||
if (!isChecked) {
|
||||
return
|
||||
}
|
||||
val newMode = when (checkedId) {
|
||||
R.id.button_standard -> ReaderMode.STANDARD
|
||||
R.id.button_webtoon -> ReaderMode.WEBTOON
|
||||
R.id.button_reversed -> ReaderMode.REVERSED
|
||||
R.id.button_vertical -> ReaderMode.VERTICAL
|
||||
else -> return
|
||||
}
|
||||
viewBinding?.switchDoubleReader?.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED
|
||||
viewBinding?.switchPullGesture?.isEnabled = newMode == ReaderMode.WEBTOON
|
||||
if (newMode == mode) {
|
||||
return
|
||||
}
|
||||
findParentCallback(Callback::class.java)?.onReaderModeChanged(newMode) ?: return
|
||||
mode = newMode
|
||||
}
|
||||
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
|
||||
settings.readerDoublePagesSensitivity = value / 100f
|
||||
}
|
||||
|
||||
private fun observeScreenOrientation() {
|
||||
orientationHelper.observeAutoOrientation()
|
||||
.onEach {
|
||||
with(requireViewBinding()) {
|
||||
buttonScreenRotate.isGone = it
|
||||
switchScreenLockRotation.isVisible = it
|
||||
updateOrientationLockSwitch()
|
||||
}
|
||||
}.launchIn(viewLifecycleScope)
|
||||
}
|
||||
override fun onButtonChecked(
|
||||
group: MaterialButtonToggleGroup?,
|
||||
checkedId: Int,
|
||||
isChecked: Boolean,
|
||||
) {
|
||||
if (!isChecked) {
|
||||
return
|
||||
}
|
||||
val newMode = when (checkedId) {
|
||||
R.id.button_standard -> ReaderMode.STANDARD
|
||||
R.id.button_webtoon -> ReaderMode.WEBTOON
|
||||
R.id.button_reversed -> ReaderMode.REVERSED
|
||||
R.id.button_vertical -> ReaderMode.VERTICAL
|
||||
else -> return
|
||||
}
|
||||
viewBinding?.run {
|
||||
switchDoubleReader.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED
|
||||
switchDoubleFoldable.isEnabled = switchDoubleReader.isEnabled
|
||||
adjustSensitivitySlider(withAnimation = true)
|
||||
}
|
||||
if (newMode == mode) {
|
||||
return
|
||||
}
|
||||
findParentCallback(Callback::class.java)?.onReaderModeChanged(newMode) ?: return
|
||||
mode = newMode
|
||||
}
|
||||
|
||||
private fun updateOrientationLockSwitch() {
|
||||
val switch = viewBinding?.switchScreenLockRotation ?: return
|
||||
switch.setOnCheckedChangeListener(null)
|
||||
switch.isChecked = orientationHelper.isLocked
|
||||
switch.setOnCheckedChangeListener(this)
|
||||
}
|
||||
private fun observeScreenOrientation() {
|
||||
orientationHelper.observeAutoOrientation()
|
||||
.onEach {
|
||||
with(requireViewBinding()) {
|
||||
buttonScreenRotate.isGone = it
|
||||
switchScreenLockRotation.isVisible = it
|
||||
updateOrientationLockSwitch()
|
||||
}
|
||||
}.launchIn(viewLifecycleScope)
|
||||
}
|
||||
|
||||
private suspend fun bindImageServerTitle() {
|
||||
viewBinding?.buttonImageServer?.text = getString(
|
||||
R.string.inline_preference_pattern,
|
||||
getString(R.string.image_server),
|
||||
imageServerDelegate.getValue() ?: getString(R.string.automatic),
|
||||
)
|
||||
}
|
||||
private fun updateOrientationLockSwitch() {
|
||||
val switch = viewBinding?.switchScreenLockRotation ?: return
|
||||
switch.setOnCheckedChangeListener(null)
|
||||
switch.isChecked = orientationHelper.isLocked
|
||||
switch.setOnCheckedChangeListener(this)
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
private suspend fun bindImageServerTitle() {
|
||||
viewBinding?.buttonImageServer?.text = getString(
|
||||
R.string.inline_preference_pattern,
|
||||
getString(R.string.image_server),
|
||||
imageServerDelegate.getValue() ?: getString(R.string.automatic),
|
||||
)
|
||||
}
|
||||
|
||||
fun onReaderModeChanged(mode: ReaderMode)
|
||||
private fun SheetReaderConfigBinding.adjustSensitivitySlider(withAnimation: Boolean) {
|
||||
val isSubOptionsVisible = switchDoubleReader.isEnabled && switchDoubleReader.isChecked
|
||||
val needTransition = withAnimation && (
|
||||
(isSubOptionsVisible != sliderDoubleSensitivity.isVisible) ||
|
||||
(isSubOptionsVisible != textDoubleSensitivity.isVisible) ||
|
||||
(isSubOptionsVisible != switchDoubleFoldable.isVisible)
|
||||
)
|
||||
if (needTransition) {
|
||||
TransitionManager.beginDelayedTransition(layoutMain)
|
||||
}
|
||||
sliderDoubleSensitivity.isVisible = isSubOptionsVisible
|
||||
textDoubleSensitivity.isVisible = isSubOptionsVisible
|
||||
switchDoubleFoldable.isVisible = isSubOptionsVisible
|
||||
}
|
||||
|
||||
fun onDoubleModeChanged(isEnabled: Boolean)
|
||||
interface Callback {
|
||||
|
||||
fun onSavePageClick()
|
||||
fun onReaderModeChanged(mode: ReaderMode)
|
||||
|
||||
fun onScrollTimerClick(isLongClick: Boolean)
|
||||
fun onDoubleModeChanged(isEnabled: Boolean)
|
||||
|
||||
fun onBookmarkClick()
|
||||
}
|
||||
fun onSavePageClick()
|
||||
|
||||
fun onScrollTimerClick(isLongClick: Boolean)
|
||||
|
||||
fun onBookmarkClick()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,26 @@ abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>(), ZoomCont
|
||||
readerAdapter = onCreateAdapter()
|
||||
|
||||
viewModel.content.observe(viewLifecycleOwner) {
|
||||
if (it.state == null && it.pages.isNotEmpty() && readerAdapter?.hasItems != true) {
|
||||
onPagesChanged(it.pages, viewModel.getCurrentState())
|
||||
} else {
|
||||
onPagesChanged(it.pages, it.state)
|
||||
// Determine which state to use for restoring position:
|
||||
// - content.state: explicitly set state (e.g., after mode switch or chapter change)
|
||||
// - getCurrentState(): current reading position saved in SavedStateHandle
|
||||
val currentState = viewModel.getCurrentState()
|
||||
val pendingState = when {
|
||||
// If content.state is null and we have pages, use getCurrentState
|
||||
it.state == null
|
||||
&& it.pages.isNotEmpty()
|
||||
&& readerAdapter?.hasItems != true -> currentState
|
||||
|
||||
// use currentState only if it matches the current pages (to avoid the error message)
|
||||
readerAdapter?.hasItems != true
|
||||
&& it.state != currentState
|
||||
&& currentState != null
|
||||
&& it.pages.any { page -> page.chapterId == currentState.chapterId } -> currentState
|
||||
|
||||
// Otherwise, use content.state (normal flow, mode switch, chapter change)
|
||||
else -> it.state
|
||||
}
|
||||
onPagesChanged(it.pages, pendingState)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,14 @@ import androidx.recyclerview.widget.OrientationHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.SmoothScroller.ScrollVectorProvider
|
||||
import androidx.recyclerview.widget.SnapHelper
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sign
|
||||
|
||||
class DoublePageSnapHelper : SnapHelper() {
|
||||
class DoublePageSnapHelper(private val settings: AppSettings) : SnapHelper() {
|
||||
|
||||
private lateinit var recyclerView: RecyclerView
|
||||
|
||||
@@ -248,28 +251,27 @@ class DoublePageSnapHelper : SnapHelper() {
|
||||
equal to zero.
|
||||
*/
|
||||
fun getPositionsToMove(llm: LinearLayoutManager, scroll: Int, itemSize: Int): Int {
|
||||
var positionsToMove: Int
|
||||
positionsToMove = roundUpToBlockSize(abs((scroll.toDouble()) / itemSize).roundToInt())
|
||||
if (positionsToMove < blockSize) {
|
||||
// Must move at least one block
|
||||
positionsToMove = blockSize
|
||||
} else if (positionsToMove > maxPositionsToMove) {
|
||||
// Clamp number of positions to move, so we don't get wild flinging.
|
||||
positionsToMove = maxPositionsToMove
|
||||
val sensitivity = settings.readerDoublePagesSensitivity.coerceIn(0f, 1f) * 2.5
|
||||
var positionsToMove = (scroll.toDouble() / (itemSize * (2.5 - sensitivity))).roundToInt()
|
||||
|
||||
// Apply a maximum threshold
|
||||
val maxPages = (4 * sensitivity).roundToInt().coerceAtLeast(1)
|
||||
if (positionsToMove.absoluteValue > maxPages) {
|
||||
positionsToMove = maxPages * positionsToMove.sign
|
||||
}
|
||||
if (scroll < 0) {
|
||||
positionsToMove *= -1
|
||||
|
||||
// Apply a minimum threshold
|
||||
if (positionsToMove == 0 && scroll.absoluteValue > itemSize * 0.2) {
|
||||
positionsToMove = 1 * scroll.sign
|
||||
}
|
||||
if (isRTL) {
|
||||
positionsToMove *= -1
|
||||
}
|
||||
return if (layoutDirectionHelper.isDirectionToBottom(scroll < 0)) {
|
||||
// Scrolling toward the bottom of data.
|
||||
roundDownToBlockSize(llm.findFirstVisibleItemPosition()) + positionsToMove
|
||||
|
||||
val currentPosition = if (layoutDirectionHelper.isDirectionToBottom(scroll < 0)) {
|
||||
llm.findFirstVisibleItemPosition()
|
||||
} else {
|
||||
roundDownToBlockSize(llm.findLastVisibleItemPosition()) + positionsToMove
|
||||
llm.findLastVisibleItemPosition()
|
||||
}
|
||||
// Scrolling toward the top of the data.
|
||||
val targetPos = currentPosition + positionsToMove * 2
|
||||
return roundDownToBlockSize(targetPos)
|
||||
}
|
||||
|
||||
fun isDirectionToBottom(velocityNegative: Boolean): Boolean {
|
||||
|
||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.list.lifecycle.RecyclerViewLifecycleDispatcher
|
||||
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
|
||||
import org.koitharu.kotatsu.databinding.FragmentReaderDoubleBinding
|
||||
@@ -33,6 +34,9 @@ open class DoubleReaderFragment : BaseReaderFragment<FragmentReaderDoubleBinding
|
||||
@Inject
|
||||
lateinit var pageLoader: PageLoader
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private var recyclerLifecycleDispatcher: RecyclerViewLifecycleDispatcher? = null
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
@@ -51,7 +55,7 @@ open class DoubleReaderFragment : BaseReaderFragment<FragmentReaderDoubleBinding
|
||||
addOnScrollListener(it)
|
||||
}
|
||||
addOnScrollListener(PageScrollListener())
|
||||
DoublePageSnapHelper().attachToRecyclerView(this)
|
||||
DoublePageSnapHelper(settings).attachToRecyclerView(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ class WebtoonImageView @JvmOverloads constructor(
|
||||
fun scrollTo(y: Int) {
|
||||
val maxScroll = getScrollRange()
|
||||
if (maxScroll == 0) {
|
||||
resetScaleAndCenter()
|
||||
scrollToInternal(0)
|
||||
return
|
||||
}
|
||||
scrollToInternal(y.coerceIn(0, maxScroll))
|
||||
|
||||
@@ -28,13 +28,21 @@ class WebtoonRecyclerView @JvmOverloads constructor(
|
||||
private var isFixingScroll = false
|
||||
|
||||
var isPullGestureEnabled: Boolean = false
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
setEdgeEffectFactory(
|
||||
if (value) {
|
||||
PullEffect.Factory()
|
||||
} else {
|
||||
EdgeEffectFactory()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
var pullThreshold: Float = 0.3f
|
||||
private var pullListener: OnPullGestureListener? = null
|
||||
|
||||
init {
|
||||
setEdgeEffectFactory(PullEffect.Factory())
|
||||
}
|
||||
|
||||
fun setOnPullGestureListener(listener: OnPullGestureListener?) {
|
||||
pullListener = listener
|
||||
}
|
||||
@@ -248,7 +256,7 @@ class WebtoonRecyclerView @JvmOverloads constructor(
|
||||
|
||||
override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect {
|
||||
val pullListener = (view as? WebtoonRecyclerView)?.pullListener
|
||||
return if (pullListener != null && view.isPullGestureEnabled) {
|
||||
return if (pullListener != null) {
|
||||
PullEffect(view, direction, view.pullThreshold, pullListener)
|
||||
} else {
|
||||
super.createEdgeEffect(view, direction)
|
||||
|
||||
@@ -29,120 +29,133 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||
|
||||
@AndroidEntryPoint
|
||||
class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner {
|
||||
class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner, View.OnClickListener {
|
||||
|
||||
override val viewModel by viewModels<RemoteListViewModel>()
|
||||
override val viewModel by viewModels<RemoteListViewModel>()
|
||||
|
||||
override val filterCoordinator: FilterCoordinator
|
||||
get() = viewModel.filterCoordinator
|
||||
override val filterCoordinator: FilterCoordinator
|
||||
get() = viewModel.filterCoordinator
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
addMenuProvider(RemoteListMenuProvider())
|
||||
addMenuProvider(MangaSearchMenuProvider(filterCoordinator, viewModel))
|
||||
viewModel.isRandomLoading.observe(viewLifecycleOwner, MenuInvalidator(requireActivity()))
|
||||
viewModel.onOpenManga.observeEvent(viewLifecycleOwner) { router.openDetails(it) }
|
||||
filterCoordinator.observe().distinctUntilChangedBy { it.listFilter.isEmpty() }
|
||||
.drop(1)
|
||||
.observe(viewLifecycleOwner) {
|
||||
activity?.invalidateMenu()
|
||||
}
|
||||
}
|
||||
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
addMenuProvider(RemoteListMenuProvider())
|
||||
addMenuProvider(MangaSearchMenuProvider(filterCoordinator, viewModel))
|
||||
viewModel.isRandomLoading.observe(viewLifecycleOwner, MenuInvalidator(requireActivity()))
|
||||
viewModel.onOpenManga.observeEvent(viewLifecycleOwner) { router.openDetails(it) }
|
||||
viewModel.onSourceBroken.observeEvent(viewLifecycleOwner) { showSourceBrokenWarning() }
|
||||
filterCoordinator.observe().distinctUntilChangedBy { it.listFilter.isEmpty() }
|
||||
.drop(1)
|
||||
.observe(viewLifecycleOwner) {
|
||||
activity?.invalidateMenu()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScrolledToEnd() {
|
||||
viewModel.loadNextPage()
|
||||
}
|
||||
override fun onScrolledToEnd() {
|
||||
viewModel.loadNextPage()
|
||||
}
|
||||
|
||||
override fun onCreateActionMode(
|
||||
controller: ListSelectionController,
|
||||
menuInflater: MenuInflater,
|
||||
menu: Menu
|
||||
): Boolean {
|
||||
menuInflater.inflate(R.menu.mode_remote, menu)
|
||||
return super.onCreateActionMode(controller, menuInflater, menu)
|
||||
}
|
||||
override fun onCreateActionMode(
|
||||
controller: ListSelectionController,
|
||||
menuInflater: MenuInflater,
|
||||
menu: Menu
|
||||
): Boolean {
|
||||
menuInflater.inflate(R.menu.mode_remote, menu)
|
||||
return super.onCreateActionMode(controller, menuInflater, menu)
|
||||
}
|
||||
|
||||
override fun onFilterClick(view: View?) {
|
||||
router.showFilterSheet()
|
||||
}
|
||||
override fun onFilterClick(view: View?) {
|
||||
router.showFilterSheet()
|
||||
}
|
||||
|
||||
override fun onEmptyActionClick() {
|
||||
if (filterCoordinator.isFilterApplied) {
|
||||
filterCoordinator.reset()
|
||||
} else {
|
||||
openInBrowser(null) // should never be called
|
||||
}
|
||||
}
|
||||
override fun onEmptyActionClick() {
|
||||
if (filterCoordinator.isFilterApplied) {
|
||||
filterCoordinator.reset()
|
||||
} else {
|
||||
openInBrowser(null) // should never be called
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFooterButtonClick() {
|
||||
val filter = filterCoordinator.snapshot().listFilter
|
||||
when {
|
||||
!filter.query.isNullOrEmpty() -> router.openSearch(filter.query.orEmpty(), SearchKind.SIMPLE)
|
||||
!filter.author.isNullOrEmpty() -> router.openSearch(filter.author.orEmpty(), SearchKind.AUTHOR)
|
||||
filter.tags.size == 1 -> router.openSearch(filter.tags.singleOrNull()?.title.orEmpty(), SearchKind.TAG)
|
||||
}
|
||||
}
|
||||
override fun onFooterButtonClick() {
|
||||
val filter = filterCoordinator.snapshot().listFilter
|
||||
when {
|
||||
!filter.query.isNullOrEmpty() -> router.openSearch(filter.query.orEmpty(), SearchKind.SIMPLE)
|
||||
!filter.author.isNullOrEmpty() -> router.openSearch(filter.author.orEmpty(), SearchKind.AUTHOR)
|
||||
filter.tags.size == 1 -> router.openSearch(filter.tags.singleOrNull()?.title.orEmpty(), SearchKind.TAG)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSecondaryErrorActionClick(error: Throwable) {
|
||||
openInBrowser(error.getCauseUrl())
|
||||
}
|
||||
override fun onSecondaryErrorActionClick(error: Throwable) {
|
||||
openInBrowser(error.getCauseUrl())
|
||||
}
|
||||
|
||||
private fun openInBrowser(url: String?) {
|
||||
if (url?.isHttpUrl() == true) {
|
||||
router.openBrowser(
|
||||
url = url,
|
||||
source = viewModel.source,
|
||||
title = viewModel.source.getTitle(requireContext()),
|
||||
)
|
||||
} else {
|
||||
Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
override fun onClick(v: View?) = Unit // from Snackbar, do nothing
|
||||
|
||||
private inner class RemoteListMenuProvider : MenuProvider {
|
||||
private fun openInBrowser(url: String?) {
|
||||
if (url?.isHttpUrl() == true) {
|
||||
router.openBrowser(
|
||||
url = url,
|
||||
source = viewModel.source,
|
||||
title = viewModel.source.getTitle(requireContext()),
|
||||
)
|
||||
} else {
|
||||
Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.opt_list_remote, menu)
|
||||
}
|
||||
private fun showSourceBrokenWarning() {
|
||||
val snackbar = Snackbar.make(
|
||||
viewBinding?.recyclerView ?: return,
|
||||
R.string.source_broken_warning,
|
||||
Snackbar.LENGTH_INDEFINITE,
|
||||
)
|
||||
snackbar.setAction(R.string.got_it, this)
|
||||
snackbar.show()
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||
R.id.action_source_settings -> {
|
||||
router.openSourceSettings(viewModel.source)
|
||||
true
|
||||
}
|
||||
private inner class RemoteListMenuProvider : MenuProvider {
|
||||
|
||||
R.id.action_random -> {
|
||||
viewModel.openRandom()
|
||||
true
|
||||
}
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.opt_list_remote, menu)
|
||||
}
|
||||
|
||||
R.id.action_filter -> {
|
||||
onFilterClick(null)
|
||||
true
|
||||
}
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||
R.id.action_source_settings -> {
|
||||
router.openSourceSettings(viewModel.source)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_filter_reset -> {
|
||||
filterCoordinator.reset()
|
||||
true
|
||||
}
|
||||
R.id.action_random -> {
|
||||
viewModel.openRandom()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
R.id.action_filter -> {
|
||||
onFilterClick(null)
|
||||
true
|
||||
}
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
super.onPrepareMenu(menu)
|
||||
menu.findItem(R.id.action_random)?.isEnabled = !viewModel.isRandomLoading.value
|
||||
menu.findItem(R.id.action_filter_reset)?.isVisible = filterCoordinator.isFilterApplied
|
||||
}
|
||||
}
|
||||
R.id.action_filter_reset -> {
|
||||
filterCoordinator.reset()
|
||||
true
|
||||
}
|
||||
|
||||
companion object {
|
||||
else -> false
|
||||
}
|
||||
|
||||
const val ARG_SOURCE = "provider"
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
super.onPrepareMenu(menu)
|
||||
menu.findItem(R.id.action_random)?.isEnabled = !viewModel.isRandomLoading.value
|
||||
menu.findItem(R.id.action_filter_reset)?.isVisible = filterCoordinator.isFilterApplied
|
||||
}
|
||||
}
|
||||
|
||||
fun newInstance(source: MangaSource) = RemoteListFragment().withArgs(1) {
|
||||
putString(ARG_SOURCE, source.name)
|
||||
}
|
||||
}
|
||||
companion object {
|
||||
|
||||
const val ARG_SOURCE = "provider"
|
||||
|
||||
fun newInstance(source: MangaSource) = RemoteListFragment().withArgs(1) {
|
||||
putString(ARG_SOURCE, source.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.util.sizeOrZero
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -65,6 +66,7 @@ open class RemoteListViewModel @Inject constructor(
|
||||
val source = MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE])
|
||||
val isRandomLoading = MutableStateFlow(false)
|
||||
val onOpenManga = MutableEventFlow<Manga>()
|
||||
val onSourceBroken = MutableEventFlow<Unit>()
|
||||
|
||||
protected val repository = mangaRepositoryFactory.create(source)
|
||||
private val mangaList = MutableStateFlow<List<Manga>?>(null)
|
||||
@@ -117,6 +119,11 @@ open class RemoteListViewModel @Inject constructor(
|
||||
launchJob(Dispatchers.Default) {
|
||||
sourcesRepository.trackUsage(source)
|
||||
}
|
||||
|
||||
if (source is MangaParserSource && source.isBroken) {
|
||||
// Just notify one. Will show reason in future
|
||||
onSourceBroken.call(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package org.koitharu.kotatsu.scrobbling.common.data
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
@Dao
|
||||
abstract class ScrobblingDao {
|
||||
@@ -20,4 +23,20 @@ abstract class ScrobblingDao {
|
||||
|
||||
@Query("DELETE FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
|
||||
abstract suspend fun delete(scrobbler: Int, mangaId: Long)
|
||||
|
||||
@Query("SELECT * FROM scrobblings ORDER BY scrobbler LIMIT :limit OFFSET :offset")
|
||||
protected abstract suspend fun findAll(offset: Int, limit: Int): List<ScrobblingEntity>
|
||||
|
||||
fun dumpEnabled(): Flow<ScrobblingEntity> = flow {
|
||||
val window = 10
|
||||
var offset = 0
|
||||
while (currentCoroutineContext().isActive) {
|
||||
val list = findAll(offset, window)
|
||||
if (list.isEmpty()) {
|
||||
break
|
||||
}
|
||||
offset += window
|
||||
list.forEach { emit(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
@@ -47,7 +46,7 @@ class ScrobblerConfigViewModel @Inject constructor(
|
||||
val content = scrobbler.observeAllScrobblingInfo()
|
||||
.onStart { loadingCounter.increment() }
|
||||
.onFirst { loadingCounter.decrement() }
|
||||
.catch { errorEvent.call(it) }
|
||||
.withErrorHandling()
|
||||
.map { buildContentList(it) }
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.scrobbling.discord.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.collection.ArrayMap
|
||||
import com.my.kizzyrpc.KizzyRPC
|
||||
@@ -14,6 +15,7 @@ import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import okio.utf8Size
|
||||
@@ -35,6 +37,7 @@ import javax.inject.Inject
|
||||
private const val STATUS_ONLINE = "online"
|
||||
private const val STATUS_IDLE = "idle"
|
||||
private const val BUTTON_TEXT_LIMIT = 32
|
||||
private const val DEBOUNCE_TIMEOUT = 16_000L // 16 sec
|
||||
|
||||
@ViewModelScoped
|
||||
class DiscordRpc @Inject constructor(
|
||||
@@ -49,6 +52,7 @@ class DiscordRpc @Inject constructor(
|
||||
private val appName = context.getString(R.string.app_name)
|
||||
private val appIcon = context.getString(R.string.app_icon_url)
|
||||
private val mpCache = Collections.synchronizedMap(ArrayMap<String, String>())
|
||||
private var lastUpdate = 0L
|
||||
|
||||
private var rpc: KizzyRPC? = null
|
||||
|
||||
@@ -68,6 +72,7 @@ class DiscordRpc @Inject constructor(
|
||||
fun clearRpc() = synchronized(this) {
|
||||
rpc?.closeRPC()
|
||||
rpc = null
|
||||
lastUpdate = 0L
|
||||
}
|
||||
|
||||
fun setIdle() {
|
||||
@@ -114,6 +119,10 @@ class DiscordRpc @Inject constructor(
|
||||
val prevJob = rpcUpdateJob
|
||||
rpcUpdateJob = coroutineScope.launch {
|
||||
prevJob?.cancelAndJoin()
|
||||
val debounceTime = lastUpdate + DEBOUNCE_TIMEOUT - SystemClock.elapsedRealtime()
|
||||
if (debounceTime > 0) {
|
||||
delay(debounceTime)
|
||||
}
|
||||
val hideButtons = activity.buttons?.any { it != null && it.utf8Size() > BUTTON_TEXT_LIMIT } ?: false
|
||||
val mappedActivity = activity.copy(
|
||||
assets = activity.assets?.let {
|
||||
@@ -131,6 +140,7 @@ class DiscordRpc @Inject constructor(
|
||||
status = if (idle) STATUS_IDLE else STATUS_ONLINE,
|
||||
since = activity.timestamps?.start ?: System.currentTimeMillis(),
|
||||
)
|
||||
lastUpdate = SystemClock.elapsedRealtime()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ class MALRepository @Inject constructor(
|
||||
storage.clear()
|
||||
}
|
||||
|
||||
private fun jsonToManga(json: JSONObject, sourceTitle: String): ScrobblerManga? {
|
||||
private fun jsonToManga(json: JSONObject, sourceTitle: String): ScrobblerManga {
|
||||
val node = json.getJSONObject("node")
|
||||
val title = node.getString("title")
|
||||
return ScrobblerManga(
|
||||
|
||||
@@ -169,4 +169,8 @@ class MangaSearchRepository @Inject constructor(
|
||||
null,
|
||||
)?.use { cursor -> cursor.count } ?: 0
|
||||
}
|
||||
|
||||
suspend fun getAuthors(source: MangaSource, limit: Int): List<String> {
|
||||
return db.getMangaDao().findAuthorsBySource(source.name, limit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ class SearchActivity :
|
||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
|
||||
supportActionBar?.setSubtitle(R.string.search_results)
|
||||
|
||||
addMenuProvider(SearchKindMenuProvider(this, viewModel.query, viewModel.kind))
|
||||
addMenuProvider(SearchMenuProvider(this, viewModel))
|
||||
|
||||
viewModel.list.observe(this, adapter)
|
||||
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
|
||||
|
||||
@@ -9,10 +9,9 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||
|
||||
class SearchKindMenuProvider(
|
||||
class SearchMenuProvider(
|
||||
private val activity: SearchActivity,
|
||||
private val query: String,
|
||||
private val kind: SearchKind
|
||||
private val viewModel: SearchViewModel,
|
||||
) : MenuProvider {
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
@@ -22,7 +21,7 @@ class SearchKindMenuProvider(
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
super.onPrepareMenu(menu)
|
||||
menu.findItem(
|
||||
when (kind) {
|
||||
when (viewModel.kind) {
|
||||
SearchKind.SIMPLE -> R.id.action_kind_simple
|
||||
SearchKind.TITLE -> R.id.action_kind_title
|
||||
SearchKind.AUTHOR -> R.id.action_kind_author
|
||||
@@ -32,6 +31,20 @@ class SearchKindMenuProvider(
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_filter_pinned_only -> {
|
||||
menuItem.isChecked = !menuItem.isChecked
|
||||
viewModel.setPinnedOnly(menuItem.isChecked)
|
||||
return true
|
||||
}
|
||||
|
||||
R.id.action_filter_hide_empty -> {
|
||||
menuItem.isChecked = !menuItem.isChecked
|
||||
viewModel.setHideEmpty(menuItem.isChecked)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
val newKind = when (menuItem.itemId) {
|
||||
R.id.action_kind_simple -> SearchKind.SIMPLE
|
||||
R.id.action_kind_title -> SearchKind.TITLE
|
||||
@@ -39,9 +52,9 @@ class SearchKindMenuProvider(
|
||||
R.id.action_kind_tag -> SearchKind.TAG
|
||||
else -> return false
|
||||
}
|
||||
if (newKind != kind) {
|
||||
if (newKind != viewModel.kind) {
|
||||
activity.router.openSearch(
|
||||
query = query,
|
||||
query = viewModel.query,
|
||||
kind = newKind,
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
@@ -62,6 +62,8 @@ class SearchViewModel @Inject constructor(
|
||||
val kind = savedStateHandle.get<SearchKind>(AppRouter.KEY_KIND) ?: SearchKind.SIMPLE
|
||||
|
||||
private var includeDisabledSources = MutableStateFlow(false)
|
||||
private var pinnedOnly = MutableStateFlow(false)
|
||||
private var hideEmpty = MutableStateFlow(false)
|
||||
private val results = MutableStateFlow<List<SearchResultsListModel>>(emptyList())
|
||||
|
||||
private var searchJob: Job? = null
|
||||
@@ -70,9 +72,15 @@ class SearchViewModel @Inject constructor(
|
||||
results,
|
||||
isLoading.dropWhile { !it },
|
||||
includeDisabledSources,
|
||||
) { list, loading, includeDisabled ->
|
||||
hideEmpty,
|
||||
) { list, loading, includeDisabled, hideEmptyVal ->
|
||||
val filteredList = if (hideEmptyVal) {
|
||||
list.filter { it.list.isNotEmpty() }
|
||||
} else {
|
||||
list
|
||||
}
|
||||
when {
|
||||
list.isEmpty() -> listOf(
|
||||
filteredList.isEmpty() -> listOf(
|
||||
when {
|
||||
loading -> LoadingState
|
||||
else -> EmptyState(
|
||||
@@ -84,9 +92,9 @@ class SearchViewModel @Inject constructor(
|
||||
},
|
||||
)
|
||||
|
||||
loading -> list + LoadingFooter()
|
||||
includeDisabled -> list
|
||||
else -> list + ButtonFooter(R.string.search_disabled_sources)
|
||||
loading -> filteredList + LoadingFooter()
|
||||
includeDisabled -> filteredList
|
||||
else -> filteredList + ButtonFooter(R.string.search_disabled_sources)
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
|
||||
|
||||
@@ -114,6 +122,17 @@ class SearchViewModel @Inject constructor(
|
||||
doSearch()
|
||||
}
|
||||
|
||||
fun setPinnedOnly(value: Boolean) {
|
||||
if (pinnedOnly.value != value) {
|
||||
pinnedOnly.value = value
|
||||
retry()
|
||||
}
|
||||
}
|
||||
|
||||
fun setHideEmpty(value: Boolean) {
|
||||
hideEmpty.value = value
|
||||
}
|
||||
|
||||
fun continueSearch() {
|
||||
if (includeDisabledSources.value) {
|
||||
return
|
||||
@@ -122,8 +141,12 @@ class SearchViewModel @Inject constructor(
|
||||
searchJob = launchLoadingJob(Dispatchers.Default) {
|
||||
includeDisabledSources.value = true
|
||||
prevJob?.join()
|
||||
val sources = sourcesRepository.getDisabledSources()
|
||||
.sortedByDescending { it.priority() }
|
||||
val sources = if (pinnedOnly.value) {
|
||||
emptyList()
|
||||
} else {
|
||||
sourcesRepository.getDisabledSources()
|
||||
.sortedByDescending { it.priority() }
|
||||
}
|
||||
val semaphore = Semaphore(MAX_PARALLELISM)
|
||||
sources.map { source ->
|
||||
launch {
|
||||
@@ -142,7 +165,11 @@ class SearchViewModel @Inject constructor(
|
||||
appendResult(searchHistory())
|
||||
appendResult(searchFavorites())
|
||||
appendResult(searchLocal())
|
||||
val sources = sourcesRepository.getEnabledSources()
|
||||
val sources = if (pinnedOnly.value) {
|
||||
sourcesRepository.getPinnedSources().toList()
|
||||
} else {
|
||||
sourcesRepository.getEnabledSources()
|
||||
}
|
||||
val semaphore = Semaphore(MAX_PARALLELISM)
|
||||
sources.map { source ->
|
||||
launch {
|
||||
|
||||
@@ -37,7 +37,7 @@ fun searchResultsAD(
|
||||
binding.recyclerView.addItemDecoration(selectionDecoration)
|
||||
binding.recyclerView.adapter = adapter
|
||||
val spacing = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
|
||||
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
|
||||
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing, withBottomPadding = true))
|
||||
val eventListener = AdapterDelegateClickListenerAdapter(this, itemClickListener)
|
||||
binding.buttonMore.setOnClickListener(eventListener)
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.search.ui.suggestion.adapter
|
||||
|
||||
import androidx.appcompat.widget.TooltipCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -30,7 +29,7 @@ fun searchSuggestionMangaListAD(
|
||||
left = recyclerView.paddingLeft - spacing,
|
||||
right = recyclerView.paddingRight - spacing,
|
||||
)
|
||||
recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
|
||||
recyclerView.addItemDecoration(SpacingItemDecoration(spacing, withBottomPadding = true))
|
||||
val scrollResetCallback = RecyclerViewScrollCallback(recyclerView, 0, 0)
|
||||
|
||||
bind {
|
||||
|
||||
@@ -11,11 +11,16 @@ import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.MultiSelectListPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.TwoStatePreference
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode
|
||||
import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy
|
||||
import org.koitharu.kotatsu.core.prefs.SearchSuggestionType
|
||||
import org.koitharu.kotatsu.core.prefs.TriStateOption
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
||||
import org.koitharu.kotatsu.core.util.LocaleComparator
|
||||
@@ -24,8 +29,10 @@ import org.koitharu.kotatsu.core.util.ext.postDelayed
|
||||
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
|
||||
import org.koitharu.kotatsu.core.util.ext.toList
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.names
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
|
||||
import org.koitharu.kotatsu.settings.utils.ActivityListPreference
|
||||
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
|
||||
import org.koitharu.kotatsu.settings.utils.PercentSummaryProvider
|
||||
@@ -34,106 +41,145 @@ import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AppearanceSettingsFragment :
|
||||
BasePreferenceFragment(R.string.appearance),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
BasePreferenceFragment(R.string.appearance),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
@Inject
|
||||
lateinit var activityRecreationHandle: ActivityRecreationHandle
|
||||
@Inject
|
||||
lateinit var activityRecreationHandle: ActivityRecreationHandle
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_appearance)
|
||||
findPreference<SliderPreference>(AppSettings.KEY_GRID_SIZE)?.summaryProvider = PercentSummaryProvider()
|
||||
findPreference<ListPreference>(AppSettings.KEY_LIST_MODE)?.run {
|
||||
entryValues = ListMode.entries.names()
|
||||
setDefaultValueCompat(ListMode.GRID.name)
|
||||
}
|
||||
findPreference<ListPreference>(AppSettings.KEY_PROGRESS_INDICATORS)?.run {
|
||||
entryValues = ProgressIndicatorMode.entries.names()
|
||||
setDefaultValueCompat(ProgressIndicatorMode.PERCENT_READ.name)
|
||||
}
|
||||
findPreference<ActivityListPreference>(AppSettings.KEY_APP_LOCALE)?.run {
|
||||
initLocalePicker(this)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
activityIntent = Intent(
|
||||
Settings.ACTION_APP_LOCALE_SETTINGS,
|
||||
Uri.fromParts("package", context.packageName, null),
|
||||
)
|
||||
}
|
||||
summaryProvider = Preference.SummaryProvider<ActivityListPreference> {
|
||||
val locale = AppCompatDelegate.getApplicationLocales().get(0)
|
||||
locale?.getDisplayName(locale)?.toTitleCase(locale) ?: getString(R.string.follow_system)
|
||||
}
|
||||
setDefaultValueCompat("")
|
||||
}
|
||||
findPreference<MultiSelectListPreference>(AppSettings.KEY_MANGA_LIST_BADGES)?.run {
|
||||
summaryProvider = MultiSummaryProvider(R.string.none)
|
||||
}
|
||||
bindNavSummary()
|
||||
}
|
||||
@Inject
|
||||
lateinit var appShortcutManager: AppShortcutManager
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
settings.subscribe(this)
|
||||
}
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_appearance)
|
||||
findPreference<SliderPreference>(AppSettings.KEY_GRID_SIZE)?.summaryProvider = PercentSummaryProvider()
|
||||
findPreference<ListPreference>(AppSettings.KEY_LIST_MODE)?.run {
|
||||
entryValues = ListMode.entries.names()
|
||||
setDefaultValueCompat(ListMode.GRID.name)
|
||||
}
|
||||
findPreference<ListPreference>(AppSettings.KEY_PROGRESS_INDICATORS)?.run {
|
||||
entryValues = ProgressIndicatorMode.entries.names()
|
||||
setDefaultValueCompat(ProgressIndicatorMode.PERCENT_READ.name)
|
||||
}
|
||||
findPreference<ActivityListPreference>(AppSettings.KEY_APP_LOCALE)?.run {
|
||||
initLocalePicker(this)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
activityIntent = Intent(
|
||||
Settings.ACTION_APP_LOCALE_SETTINGS,
|
||||
Uri.fromParts("package", context.packageName, null),
|
||||
)
|
||||
}
|
||||
summaryProvider = Preference.SummaryProvider<ActivityListPreference> {
|
||||
val locale = AppCompatDelegate.getApplicationLocales().get(0)
|
||||
locale?.getDisplayName(locale)?.toTitleCase(locale) ?: getString(R.string.follow_system)
|
||||
}
|
||||
setDefaultValueCompat("")
|
||||
}
|
||||
findPreference<MultiSelectListPreference>(AppSettings.KEY_MANGA_LIST_BADGES)?.run {
|
||||
summaryProvider = MultiSummaryProvider(R.string.none)
|
||||
}
|
||||
findPreference<Preference>(AppSettings.KEY_SHORTCUTS)?.isVisible =
|
||||
appShortcutManager.isDynamicShortcutsAvailable()
|
||||
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
|
||||
?.isChecked = !settings.appPassword.isNullOrEmpty()
|
||||
findPreference<ListPreference>(AppSettings.KEY_SCREENSHOTS_POLICY)?.run {
|
||||
entryValues = ScreenshotsPolicy.entries.names()
|
||||
setDefaultValueCompat(ScreenshotsPolicy.ALLOW.name)
|
||||
}
|
||||
findPreference<MultiSelectListPreference>(AppSettings.KEY_SEARCH_SUGGESTION_TYPES)?.let { pref ->
|
||||
pref.entryValues = SearchSuggestionType.entries.names()
|
||||
pref.entries = SearchSuggestionType.entries.map { pref.context.getString(it.titleResId) }.toTypedArray()
|
||||
pref.summaryProvider = MultiSummaryProvider(R.string.none)
|
||||
pref.values = settings.searchSuggestionTypes.mapToSet { it.name }
|
||||
}
|
||||
bindNavSummary()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
settings.unsubscribe(this)
|
||||
super.onDestroyView()
|
||||
}
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
settings.subscribe(this)
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
|
||||
when (key) {
|
||||
AppSettings.KEY_THEME -> {
|
||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||
}
|
||||
override fun onDestroyView() {
|
||||
settings.unsubscribe(this)
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
AppSettings.KEY_COLOR_THEME,
|
||||
AppSettings.KEY_THEME_AMOLED,
|
||||
-> {
|
||||
postRestart()
|
||||
}
|
||||
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
|
||||
when (key) {
|
||||
AppSettings.KEY_THEME -> {
|
||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||
}
|
||||
|
||||
AppSettings.KEY_APP_LOCALE -> {
|
||||
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
||||
}
|
||||
AppSettings.KEY_COLOR_THEME,
|
||||
AppSettings.KEY_THEME_AMOLED,
|
||||
-> {
|
||||
postRestart()
|
||||
}
|
||||
|
||||
AppSettings.KEY_NAV_MAIN -> {
|
||||
bindNavSummary()
|
||||
}
|
||||
}
|
||||
}
|
||||
AppSettings.KEY_APP_LOCALE -> {
|
||||
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
||||
}
|
||||
|
||||
private fun postRestart() {
|
||||
viewLifecycleOwner.lifecycle.postDelayed(400) {
|
||||
activityRecreationHandle.recreateAll()
|
||||
}
|
||||
}
|
||||
AppSettings.KEY_NAV_MAIN -> {
|
||||
bindNavSummary()
|
||||
}
|
||||
|
||||
private fun initLocalePicker(preference: ListPreference) {
|
||||
val locales = preference.context.getLocalesConfig()
|
||||
.toList()
|
||||
.sortedWithSafe(LocaleComparator())
|
||||
preference.entries = Array(locales.size + 1) { i ->
|
||||
if (i == 0) {
|
||||
getString(R.string.follow_system)
|
||||
} else {
|
||||
val lc = locales[i - 1]
|
||||
lc.getDisplayName(lc).toTitleCase(lc)
|
||||
}
|
||||
}
|
||||
preference.entryValues = Array(locales.size + 1) { i ->
|
||||
if (i == 0) {
|
||||
""
|
||||
} else {
|
||||
locales[i - 1].toLanguageTag()
|
||||
}
|
||||
}
|
||||
}
|
||||
AppSettings.KEY_APP_PASSWORD -> {
|
||||
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
|
||||
?.isChecked = !settings.appPassword.isNullOrEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindNavSummary() {
|
||||
val pref = findPreference<Preference>(AppSettings.KEY_NAV_MAIN) ?: return
|
||||
pref.summary = settings.mainNavItems.joinToString {
|
||||
getString(it.title)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun postRestart() {
|
||||
viewLifecycleOwner.lifecycle.postDelayed(400) {
|
||||
activityRecreationHandle.recreateAll()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initLocalePicker(preference: ListPreference) {
|
||||
val locales = preference.context.getLocalesConfig()
|
||||
.toList()
|
||||
.sortedWithSafe(LocaleComparator())
|
||||
preference.entries = Array(locales.size + 1) { i ->
|
||||
if (i == 0) {
|
||||
getString(R.string.follow_system)
|
||||
} else {
|
||||
val lc = locales[i - 1]
|
||||
lc.getDisplayName(lc).toTitleCase(lc)
|
||||
}
|
||||
}
|
||||
preference.entryValues = Array(locales.size + 1) { i ->
|
||||
if (i == 0) {
|
||||
""
|
||||
} else {
|
||||
locales[i - 1].toLanguageTag()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindNavSummary() {
|
||||
val pref = findPreference<Preference>(AppSettings.KEY_NAV_MAIN) ?: return
|
||||
pref.summary = settings.mainNavItems.joinToString {
|
||||
getString(it.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.Preference
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.network.DoHProvider
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
|
||||
import org.koitharu.kotatsu.parsers.util.names
|
||||
import java.net.Proxy
|
||||
|
||||
class NetworkSettingsFragment :
|
||||
BasePreferenceFragment(R.string.network),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_network)
|
||||
findPreference<ListPreference>(AppSettings.KEY_DOH)?.run {
|
||||
entryValues = DoHProvider.entries.names()
|
||||
setDefaultValueCompat(DoHProvider.NONE.name)
|
||||
}
|
||||
bindProxySummary()
|
||||
}
|
||||
|
||||
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_SSL_BYPASS -> {
|
||||
Snackbar.make(listView, R.string.settings_apply_restart_required, Snackbar.LENGTH_INDEFINITE).show()
|
||||
}
|
||||
|
||||
AppSettings.KEY_PROXY_TYPE,
|
||||
AppSettings.KEY_PROXY_ADDRESS,
|
||||
AppSettings.KEY_PROXY_PORT -> {
|
||||
bindProxySummary()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindProxySummary() {
|
||||
findPreference<Preference>(AppSettings.KEY_PROXY)?.run {
|
||||
val type = settings.proxyType
|
||||
val address = settings.proxyAddress
|
||||
val port = settings.proxyPort
|
||||
summary = when {
|
||||
type == Proxy.Type.DIRECT -> context.getString(R.string.disabled)
|
||||
address.isNullOrEmpty() || port == 0 -> context.getString(R.string.invalid_proxy_configuration)
|
||||
else -> "$address:$port"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,8 @@ class RootSettingsFragment : BasePreferenceFragment(0) {
|
||||
addPreferencesFromResource(R.xml.pref_root_debug)
|
||||
bindPreferenceSummary("appearance", R.string.theme, R.string.list_mode, R.string.language)
|
||||
bindPreferenceSummary("reader", R.string.read_mode, R.string.scale_mode, R.string.switch_pages)
|
||||
bindPreferenceSummary("network", R.string.proxy, R.string.dns_over_https, R.string.prefetch_content)
|
||||
bindPreferenceSummary("userdata", R.string.protect_application, R.string.backup_restore, R.string.data_deletion)
|
||||
bindPreferenceSummary("network", R.string.storage_usage, R.string.proxy, R.string.prefetch_content)
|
||||
bindPreferenceSummary("userdata", R.string.create_or_restore_backup, R.string.periodic_backups)
|
||||
bindPreferenceSummary("downloads", R.string.manga_save_location, R.string.downloads_wifi_only)
|
||||
bindPreferenceSummary("tracker", R.string.track_sources, R.string.notifications_settings)
|
||||
bindPreferenceSummary("services", R.string.suggestions, R.string.sync, R.string.tracking)
|
||||
|
||||
@@ -39,7 +39,7 @@ import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment
|
||||
import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.userdata.BackupsSettingsFragment
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SettingsActivity :
|
||||
@@ -146,7 +146,7 @@ class SettingsActivity :
|
||||
val fragment = when (intent?.action) {
|
||||
AppRouter.ACTION_READER -> ReaderSettingsFragment()
|
||||
AppRouter.ACTION_SUGGESTIONS -> SuggestionsSettingsFragment()
|
||||
AppRouter.ACTION_HISTORY -> UserDataSettingsFragment()
|
||||
AppRouter.ACTION_HISTORY -> BackupsSettingsFragment()
|
||||
AppRouter.ACTION_TRACKER -> TrackerSettingsFragment()
|
||||
AppRouter.ACTION_PERIODIC_BACKUP -> PeriodicalBackupSettingsFragment()
|
||||
AppRouter.ACTION_SOURCES -> SourcesSettingsFragment()
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.Preference
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.network.DoHProvider
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
|
||||
import org.koitharu.kotatsu.parsers.util.names
|
||||
import org.koitharu.kotatsu.settings.userdata.storage.StorageUsagePreference
|
||||
import java.net.Proxy
|
||||
|
||||
class StorageAndNetworkSettingsFragment :
|
||||
BasePreferenceFragment(R.string.storage_and_network),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
private val viewModel by viewModels<StorageAndNetworkSettingsViewModel>()
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_network_storage)
|
||||
findPreference<ListPreference>(AppSettings.KEY_DOH)?.run {
|
||||
entryValues = DoHProvider.entries.names()
|
||||
setDefaultValueCompat(DoHProvider.NONE.name)
|
||||
}
|
||||
bindProxySummary()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
|
||||
settings.subscribe(this)
|
||||
findPreference<StorageUsagePreference>(AppSettings.KEY_STORAGE_USAGE)?.let { pref ->
|
||||
viewModel.storageUsage.observe(viewLifecycleOwner, pref)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
settings.unsubscribe(this)
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
|
||||
when (key) {
|
||||
AppSettings.KEY_SSL_BYPASS -> {
|
||||
Snackbar.make(listView, R.string.settings_apply_restart_required, Snackbar.LENGTH_INDEFINITE).show()
|
||||
}
|
||||
|
||||
AppSettings.KEY_PROXY_TYPE,
|
||||
AppSettings.KEY_PROXY_ADDRESS,
|
||||
AppSettings.KEY_PROXY_PORT -> {
|
||||
bindProxySummary()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindProxySummary() {
|
||||
findPreference<Preference>(AppSettings.KEY_PROXY)?.run {
|
||||
val type = settings.proxyType
|
||||
val address = settings.proxyAddress
|
||||
val port = settings.proxyPort
|
||||
summary = when {
|
||||
type == Proxy.Type.DIRECT -> context.getString(R.string.disabled)
|
||||
address.isNullOrEmpty() || port == 0 -> context.getString(R.string.invalid_proxy_configuration)
|
||||
else -> "$address:$port"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.settings.userdata.storage.StorageUsage
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class StorageAndNetworkSettingsViewModel @Inject constructor(
|
||||
private val storageManager: LocalStorageManager,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val storageUsage: StateFlow<StorageUsage?> = flow {
|
||||
emit(loadStorageUsage())
|
||||
}.withErrorHandling()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(1000), null)
|
||||
|
||||
private suspend fun loadStorageUsage(): StorageUsage {
|
||||
val pagesCacheSize = storageManager.computeCacheSize(CacheDir.PAGES)
|
||||
val otherCacheSize = storageManager.computeCacheSize() - pagesCacheSize
|
||||
val storageSize = storageManager.computeStorageSize()
|
||||
val availableSpace = storageManager.computeAvailableSize()
|
||||
val totalBytes = pagesCacheSize + otherCacheSize + storageSize + availableSpace
|
||||
return StorageUsage(
|
||||
savedManga = StorageUsage.Item(
|
||||
bytes = storageSize,
|
||||
percent = (storageSize.toDouble() / totalBytes).toFloat(),
|
||||
),
|
||||
pagesCache = StorageUsage.Item(
|
||||
bytes = pagesCacheSize,
|
||||
percent = (pagesCacheSize.toDouble() / totalBytes).toFloat(),
|
||||
),
|
||||
otherCache = StorageUsage.Item(
|
||||
bytes = otherCacheSize,
|
||||
percent = (otherCacheSize.toDouble() / totalBytes).toFloat(),
|
||||
),
|
||||
available = StorageUsage.Item(
|
||||
bytes = availableSpace,
|
||||
percent = (availableSpace.toDouble() / totalBytes).toFloat(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,7 @@ class AppUpdateActivity : BaseActivity<ActivityAppUpdateBinding>(), View.OnClick
|
||||
viewModel.installIntent.value?.let { intent ->
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
} catch (e: Exception) {
|
||||
onError(e)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.core.net.toUri
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
@@ -18,7 +19,6 @@ import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.requireValue
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
@HiltViewModel
|
||||
class AppUpdateViewModel @Inject constructor(
|
||||
@@ -79,7 +79,7 @@ class AppUpdateViewModel @Inject constructor(
|
||||
private suspend fun observeDownload(id: Long) {
|
||||
val query = DownloadManager.Query()
|
||||
query.setFilterById(id)
|
||||
while (coroutineContext.isActive) {
|
||||
while (currentCoroutineContext().isActive) {
|
||||
downloadManager.query(query).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val bytesDownloaded = cursor.getLong(
|
||||
|
||||
@@ -24,7 +24,7 @@ class DiscordSettingsFragment : BasePreferenceFragment(R.string.discord) {
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_discord)
|
||||
findPreference<EditTextPreference>(AppSettings.Companion.KEY_DISCORD_TOKEN)?.let { pref ->
|
||||
findPreference<EditTextPreference>(AppSettings.KEY_DISCORD_TOKEN)?.let { pref ->
|
||||
pref.dialogMessage = pref.context.getString(
|
||||
R.string.discord_token_description,
|
||||
pref.context.getString(R.string.sign_in),
|
||||
@@ -44,21 +44,21 @@ class DiscordSettingsFragment : BasePreferenceFragment(R.string.discord) {
|
||||
}
|
||||
|
||||
override fun onDisplayPreferenceDialog(preference: Preference) {
|
||||
if (preference is EditTextPreference && preference.key == AppSettings.Companion.KEY_DISCORD_TOKEN) {
|
||||
if (parentFragmentManager.findFragmentByTag(TokenDialogFragment.Companion.DIALOG_FRAGMENT_TAG) != null) {
|
||||
if (preference is EditTextPreference && preference.key == AppSettings.KEY_DISCORD_TOKEN) {
|
||||
if (parentFragmentManager.findFragmentByTag(TokenDialogFragment.DIALOG_FRAGMENT_TAG) != null) {
|
||||
return
|
||||
}
|
||||
val f = TokenDialogFragment.newInstance(preference.key)
|
||||
@Suppress("DEPRECATION")
|
||||
f.setTargetFragment(this, 0)
|
||||
f.show(parentFragmentManager, TokenDialogFragment.Companion.DIALOG_FRAGMENT_TAG)
|
||||
f.show(parentFragmentManager, TokenDialogFragment.DIALOG_FRAGMENT_TAG)
|
||||
return
|
||||
}
|
||||
super.onDisplayPreferenceDialog(preference)
|
||||
}
|
||||
|
||||
private fun bindTokenPreference(state: TokenState, token: String?) {
|
||||
val pref = findPreference<EditTextPreference>(AppSettings.Companion.KEY_DISCORD_TOKEN) ?: return
|
||||
val pref = findPreference<EditTextPreference>(AppSettings.KEY_DISCORD_TOKEN) ?: return
|
||||
when (state) {
|
||||
TokenState.EMPTY -> {
|
||||
pref.icon = null
|
||||
|
||||
@@ -34,7 +34,7 @@ class DiscordSettingsViewModel @Inject constructor(
|
||||
TokenState.CHECKING to settings.discordToken,
|
||||
)
|
||||
|
||||
private suspend fun checkToken(): Flow<Pair<TokenState, String?>> = flow {
|
||||
private fun checkToken(): Flow<Pair<TokenState, String?>> = flow {
|
||||
val token = settings.discordToken
|
||||
if (!settings.isDiscordRpcEnabled) {
|
||||
emit(
|
||||
|
||||
@@ -11,6 +11,6 @@ data class SettingsItem(
|
||||
) : ListModel {
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
return other is SettingsItem && other.key == key
|
||||
return other is SettingsItem && other.key == key && other.fragmentClass == fragmentClass
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,106 +13,118 @@ import org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragme
|
||||
import org.koitharu.kotatsu.core.LocalizedAppContext
|
||||
import org.koitharu.kotatsu.settings.AppearanceSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.DownloadsSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.NetworkSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.ProxySettingsFragment
|
||||
import org.koitharu.kotatsu.settings.ReaderSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.ServicesSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.StorageAndNetworkSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.SuggestionsSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.about.AboutSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.discord.DiscordSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.userdata.storage.StorageManageSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.userdata.BackupsSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.userdata.storage.DataCleanupSettingsFragment
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
@SuppressLint("RestrictedApi")
|
||||
class SettingsSearchHelper @Inject constructor(
|
||||
@LocalizedAppContext private val context: Context,
|
||||
@LocalizedAppContext private val context: Context,
|
||||
) {
|
||||
|
||||
fun inflatePreferences(): List<SettingsItem> {
|
||||
val preferenceManager = PreferenceManager(context)
|
||||
val result = ArrayList<SettingsItem>()
|
||||
preferenceManager.inflateTo(result, R.xml.pref_appearance, emptyList(), AppearanceSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_sources, emptyList(), SourcesSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_reader, emptyList(), ReaderSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_network, emptyList(), NetworkSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_user_data, emptyList(), UserDataSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(
|
||||
result,
|
||||
R.xml.pref_storage,
|
||||
listOf(context.getString(R.string.data_and_privacy)),
|
||||
StorageManageSettingsFragment::class.java,
|
||||
)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_downloads, emptyList(), DownloadsSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_tracker, emptyList(), TrackerSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_services, emptyList(), ServicesSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_about, emptyList(), AboutSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(
|
||||
result,
|
||||
R.xml.pref_backup_periodic,
|
||||
listOf(context.getString(R.string.data_and_privacy)),
|
||||
PeriodicalBackupSettingsFragment::class.java,
|
||||
)
|
||||
preferenceManager.inflateTo(
|
||||
result,
|
||||
R.xml.pref_proxy,
|
||||
listOf(context.getString(R.string.proxy)),
|
||||
ProxySettingsFragment::class.java,
|
||||
)
|
||||
preferenceManager.inflateTo(
|
||||
result,
|
||||
R.xml.pref_suggestions,
|
||||
listOf(context.getString(R.string.suggestions)),
|
||||
SuggestionsSettingsFragment::class.java,
|
||||
)
|
||||
preferenceManager.inflateTo(
|
||||
result,
|
||||
R.xml.pref_sources,
|
||||
listOf(context.getString(R.string.remote_sources)),
|
||||
SourcesSettingsFragment::class.java,
|
||||
)
|
||||
return result
|
||||
}
|
||||
fun inflatePreferences(): List<SettingsItem> {
|
||||
val preferenceManager = PreferenceManager(context)
|
||||
val result = ArrayList<SettingsItem>()
|
||||
preferenceManager.inflateTo(result, R.xml.pref_appearance, emptyList(), AppearanceSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_sources, emptyList(), SourcesSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_reader, emptyList(), ReaderSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(
|
||||
result,
|
||||
R.xml.pref_network_storage,
|
||||
emptyList(),
|
||||
StorageAndNetworkSettingsFragment::class.java,
|
||||
)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_backups, emptyList(), BackupsSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(
|
||||
result,
|
||||
R.xml.pref_data_cleanup,
|
||||
listOf(context.getString(R.string.storage_and_network)),
|
||||
DataCleanupSettingsFragment::class.java,
|
||||
)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_downloads, emptyList(), DownloadsSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_tracker, emptyList(), TrackerSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_services, emptyList(), ServicesSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_about, emptyList(), AboutSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(
|
||||
result,
|
||||
R.xml.pref_backup_periodic,
|
||||
listOf(context.getString(R.string.backup_restore)),
|
||||
PeriodicalBackupSettingsFragment::class.java,
|
||||
)
|
||||
preferenceManager.inflateTo(
|
||||
result,
|
||||
R.xml.pref_proxy,
|
||||
listOf(context.getString(R.string.storage_and_network)),
|
||||
ProxySettingsFragment::class.java,
|
||||
)
|
||||
preferenceManager.inflateTo(
|
||||
result,
|
||||
R.xml.pref_suggestions,
|
||||
listOf(context.getString(R.string.services)),
|
||||
SuggestionsSettingsFragment::class.java,
|
||||
)
|
||||
preferenceManager.inflateTo(
|
||||
result,
|
||||
R.xml.pref_discord,
|
||||
listOf(context.getString(R.string.services)),
|
||||
DiscordSettingsFragment::class.java,
|
||||
)
|
||||
preferenceManager.inflateTo(
|
||||
result,
|
||||
R.xml.pref_sources,
|
||||
listOf(),
|
||||
SourcesSettingsFragment::class.java,
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
private fun PreferenceManager.inflateTo(
|
||||
result: MutableList<SettingsItem>,
|
||||
@XmlRes resId: Int,
|
||||
breadcrumbs: List<String>,
|
||||
fragmentClass: Class<out PreferenceFragmentCompat>
|
||||
) {
|
||||
val screen = inflateFromResource(context, resId, null)
|
||||
val screenTitle = screen.title?.toString()
|
||||
screen.inflateTo(
|
||||
result = result,
|
||||
breadcrumbs = if (screenTitle.isNullOrEmpty()) breadcrumbs else breadcrumbs + screenTitle,
|
||||
fragmentClass = fragmentClass,
|
||||
)
|
||||
}
|
||||
private fun PreferenceManager.inflateTo(
|
||||
result: MutableList<SettingsItem>,
|
||||
@XmlRes resId: Int,
|
||||
breadcrumbs: List<String>,
|
||||
fragmentClass: Class<out PreferenceFragmentCompat>
|
||||
) {
|
||||
val screen = inflateFromResource(context, resId, null)
|
||||
val screenTitle = screen.title?.toString()
|
||||
screen.inflateTo(
|
||||
result = result,
|
||||
breadcrumbs = if (screenTitle.isNullOrEmpty()) breadcrumbs else breadcrumbs + screenTitle,
|
||||
fragmentClass = fragmentClass,
|
||||
)
|
||||
}
|
||||
|
||||
private fun PreferenceScreen.inflateTo(
|
||||
result: MutableList<SettingsItem>,
|
||||
breadcrumbs: List<String>,
|
||||
fragmentClass: Class<out PreferenceFragmentCompat>
|
||||
): Unit = repeat(preferenceCount) { i ->
|
||||
val pref = this[i]
|
||||
if (pref is PreferenceScreen) {
|
||||
val screenTitle = pref.title?.toString()
|
||||
pref.inflateTo(
|
||||
result = result,
|
||||
breadcrumbs = if (screenTitle.isNullOrEmpty()) breadcrumbs else breadcrumbs + screenTitle,
|
||||
fragmentClass = fragmentClass,
|
||||
)
|
||||
} else {
|
||||
result.add(
|
||||
SettingsItem(
|
||||
key = pref.key ?: return@repeat,
|
||||
title = pref.title ?: return@repeat,
|
||||
breadcrumbs = breadcrumbs,
|
||||
fragmentClass = fragmentClass,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
private fun PreferenceScreen.inflateTo(
|
||||
result: MutableList<SettingsItem>,
|
||||
breadcrumbs: List<String>,
|
||||
fragmentClass: Class<out PreferenceFragmentCompat>
|
||||
): Unit = repeat(preferenceCount) { i ->
|
||||
val pref = this[i]
|
||||
if (pref is PreferenceScreen) {
|
||||
val screenTitle = pref.title?.toString()
|
||||
pref.inflateTo(
|
||||
result = result,
|
||||
breadcrumbs = if (screenTitle.isNullOrEmpty()) breadcrumbs else breadcrumbs + screenTitle,
|
||||
fragmentClass = fragmentClass,
|
||||
)
|
||||
} else {
|
||||
result.add(
|
||||
SettingsItem(
|
||||
key = pref.key ?: return@repeat,
|
||||
title = pref.title ?: return@repeat,
|
||||
breadcrumbs = breadcrumbs,
|
||||
fragmentClass = fragmentClass,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.TriStateOption
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
@@ -31,6 +32,10 @@ class SourcesSettingsFragment : BasePreferenceFragment(R.string.remote_sources),
|
||||
entries = SourcesSortOrder.entries.map { context.getString(it.titleResId) }.toTypedArray()
|
||||
setDefaultValueCompat(SourcesSortOrder.MANUAL.name)
|
||||
}
|
||||
findPreference<ListPreference>(AppSettings.KEY_INCOGNITO_NSFW)?.run {
|
||||
entryValues = TriStateOption.entries.names()
|
||||
setDefaultValueCompat(TriStateOption.ASK.name)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
||||
@@ -1,38 +1,66 @@
|
||||
package org.koitharu.kotatsu.settings.storage.directories
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.text.bold
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.color
|
||||
import androidx.core.view.isGone
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.drawableStart
|
||||
import org.koitharu.kotatsu.core.util.FileSize
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.setTooltipCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ItemStorageConfigBinding
|
||||
import org.koitharu.kotatsu.settings.storage.DirectoryModel
|
||||
import org.koitharu.kotatsu.databinding.ItemStorageConfig2Binding
|
||||
|
||||
fun directoryConfigAD(
|
||||
clickListener: OnListItemClickListener<DirectoryModel>,
|
||||
) = adapterDelegateViewBinding<DirectoryModel, DirectoryModel, ItemStorageConfigBinding>(
|
||||
{ layoutInflater, parent -> ItemStorageConfigBinding.inflate(layoutInflater, parent, false) },
|
||||
clickListener: OnListItemClickListener<DirectoryConfigModel>,
|
||||
) = adapterDelegateViewBinding<DirectoryConfigModel, DirectoryConfigModel, ItemStorageConfig2Binding>(
|
||||
{ layoutInflater, parent -> ItemStorageConfig2Binding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
binding.buttonRemove.setOnClickListener { v -> clickListener.onItemClick(item, v) }
|
||||
binding.buttonRemove.setTooltipCompat(binding.buttonRemove.contentDescription)
|
||||
binding.buttonRemove.setOnClickListener { v -> clickListener.onItemClick(item, v) }
|
||||
binding.buttonRemove.setTooltipCompat(binding.buttonRemove.contentDescription)
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.title ?: getString(item.titleRes)
|
||||
binding.textViewSubtitle.textAndVisible = item.file?.absolutePath
|
||||
binding.buttonRemove.isVisible = item.isRemovable
|
||||
binding.buttonRemove.isEnabled = !item.isChecked
|
||||
binding.textViewTitle.drawableStart = if (!item.isAvailable) {
|
||||
ContextCompat.getDrawable(context, R.drawable.ic_alert_outline)?.apply {
|
||||
setTint(ContextCompat.getColor(context, R.color.warning))
|
||||
}
|
||||
} else if (item.isChecked) {
|
||||
ContextCompat.getDrawable(context, R.drawable.ic_download)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
bind {
|
||||
binding.textViewTitle.text = item.title
|
||||
binding.textViewSubtitle.text = item.path.absolutePath
|
||||
binding.buttonRemove.isGone = item.isAppPrivate
|
||||
binding.buttonRemove.isEnabled = !item.isDefault
|
||||
binding.spacer.visibility = if (item.isAppPrivate) {
|
||||
View.INVISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
binding.textViewInfo.textAndVisible = buildSpannedString {
|
||||
if (item.isDefault) {
|
||||
bold {
|
||||
append(getString(R.string.download_default_directory))
|
||||
}
|
||||
}
|
||||
if (!item.isAccessible) {
|
||||
if (isNotEmpty()) appendLine()
|
||||
color(
|
||||
context.getThemeColor(
|
||||
androidx.appcompat.R.attr.colorError,
|
||||
ContextCompat.getColor(context, R.color.common_red),
|
||||
),
|
||||
) {
|
||||
append(getString(R.string.no_write_permission_to_file))
|
||||
}
|
||||
}
|
||||
if (item.isAppPrivate) {
|
||||
if (isNotEmpty()) appendLine()
|
||||
append(getString(R.string.private_app_directory_warning))
|
||||
}
|
||||
}
|
||||
binding.indicatorSize.max = FileSize.BYTES.convert(item.available, FileSize.KILOBYTES).toInt()
|
||||
binding.indicatorSize.progress = FileSize.BYTES.convert(item.size, FileSize.KILOBYTES).toInt()
|
||||
binding.textViewSize.text = context.getString(
|
||||
R.string.available_pattern,
|
||||
FileSize.BYTES.format(context, item.available),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.koitharu.kotatsu.settings.storage.directories
|
||||
|
||||
import androidx.recyclerview.widget.DiffUtil.ItemCallback
|
||||
|
||||
class DirectoryConfigDiffCallback : ItemCallback<DirectoryConfigModel>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: DirectoryConfigModel, newItem: DirectoryConfigModel): Boolean {
|
||||
return oldItem.path == newItem.path
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: DirectoryConfigModel, newItem: DirectoryConfigModel): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
override fun getChangePayload(oldItem: DirectoryConfigModel, newItem: DirectoryConfigModel): Any? {
|
||||
return if (oldItem.isDefault != newItem.isDefault) {
|
||||
Unit
|
||||
} else {
|
||||
super.getChangePayload(oldItem, newItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.koitharu.kotatsu.settings.storage.directories
|
||||
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import java.io.File
|
||||
|
||||
data class DirectoryConfigModel(
|
||||
val title: String,
|
||||
val path: File,
|
||||
val isDefault: Boolean,
|
||||
val isAppPrivate: Boolean,
|
||||
val isAccessible: Boolean,
|
||||
val size: Long,
|
||||
val available: Long,
|
||||
) : ListModel {
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
return other is DirectoryConfigModel && path == other.path
|
||||
}
|
||||
}
|
||||
@@ -20,18 +20,17 @@ import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
||||
import org.koitharu.kotatsu.databinding.ActivityMangaDirectoriesBinding
|
||||
import org.koitharu.kotatsu.settings.storage.DirectoryDiffCallback
|
||||
import org.koitharu.kotatsu.settings.storage.DirectoryModel
|
||||
import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MangaDirectoriesActivity : BaseActivity<ActivityMangaDirectoriesBinding>(),
|
||||
OnListItemClickListener<DirectoryModel>, View.OnClickListener {
|
||||
OnListItemClickListener<DirectoryConfigModel>, View.OnClickListener {
|
||||
|
||||
private val viewModel: MangaDirectoriesViewModel by viewModels()
|
||||
private val pickFileTreeLauncher = OpenDocumentTreeHelper(
|
||||
@@ -63,8 +62,10 @@ class MangaDirectoriesActivity : BaseActivity<ActivityMangaDirectoriesBinding>()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityMangaDirectoriesBinding.inflate(layoutInflater))
|
||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
|
||||
val adapter = AsyncListDifferDelegationAdapter(DirectoryDiffCallback(), directoryConfigAD(this))
|
||||
viewBinding.recyclerView.adapter = adapter
|
||||
val adapter = AsyncListDifferDelegationAdapter(DirectoryConfigDiffCallback(), directoryConfigAD(this))
|
||||
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing_large)
|
||||
viewBinding.recyclerView.adapter = adapter
|
||||
viewBinding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing, withBottomPadding = false))
|
||||
viewBinding.fabAdd.setOnClickListener(this)
|
||||
viewModel.items.observe(this) { adapter.items = it }
|
||||
viewModel.isLoading.observe(this) { viewBinding.progressBar.isVisible = it }
|
||||
@@ -76,8 +77,8 @@ class MangaDirectoriesActivity : BaseActivity<ActivityMangaDirectoriesBinding>()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onItemClick(item: DirectoryModel, view: View) {
|
||||
viewModel.onRemoveClick(item.file ?: return)
|
||||
override fun onItemClick(item: DirectoryConfigModel, view: View) {
|
||||
viewModel.onRemoveClick(item.path)
|
||||
}
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.settings.storage.directories
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.StatFs
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -8,82 +9,87 @@ import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.computeSize
|
||||
import org.koitharu.kotatsu.core.util.ext.isReadable
|
||||
import org.koitharu.kotatsu.core.util.ext.isWriteable
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.settings.storage.DirectoryModel
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class MangaDirectoriesViewModel @Inject constructor(
|
||||
private val storageManager: LocalStorageManager,
|
||||
private val settings: AppSettings,
|
||||
private val storageManager: LocalStorageManager,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val items = MutableStateFlow(emptyList<DirectoryModel>())
|
||||
private var loadingJob: Job? = null
|
||||
val items = MutableStateFlow(emptyList<DirectoryConfigModel>())
|
||||
private var loadingJob: Job? = null
|
||||
|
||||
init {
|
||||
loadList()
|
||||
}
|
||||
init {
|
||||
loadList()
|
||||
}
|
||||
|
||||
fun updateList() {
|
||||
loadList()
|
||||
}
|
||||
fun updateList() {
|
||||
loadList()
|
||||
}
|
||||
|
||||
fun onCustomDirectoryPicked(uri: Uri) {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
loadingJob?.cancelAndJoin()
|
||||
storageManager.takePermissions(uri)
|
||||
val dir = storageManager.resolveUri(uri)
|
||||
if (!dir.canRead()) {
|
||||
throw AccessDeniedException(dir)
|
||||
}
|
||||
if (dir !in storageManager.getApplicationStorageDirs()) {
|
||||
settings.userSpecifiedMangaDirectories += dir
|
||||
loadList()
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onCustomDirectoryPicked(uri: Uri) {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
loadingJob?.cancelAndJoin()
|
||||
storageManager.takePermissions(uri)
|
||||
val dir = storageManager.resolveUri(uri)
|
||||
if (!dir.canRead()) {
|
||||
throw AccessDeniedException(dir)
|
||||
}
|
||||
if (dir !in storageManager.getApplicationStorageDirs()) {
|
||||
settings.userSpecifiedMangaDirectories += dir
|
||||
loadList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onRemoveClick(directory: File) {
|
||||
settings.userSpecifiedMangaDirectories -= directory
|
||||
if (settings.mangaStorageDir == directory) {
|
||||
settings.mangaStorageDir = null
|
||||
}
|
||||
loadList()
|
||||
}
|
||||
fun onRemoveClick(directory: File) {
|
||||
settings.userSpecifiedMangaDirectories -= directory
|
||||
if (settings.mangaStorageDir == directory) {
|
||||
settings.mangaStorageDir = null
|
||||
}
|
||||
loadList()
|
||||
}
|
||||
|
||||
private fun loadList() {
|
||||
val prevJob = loadingJob
|
||||
loadingJob = launchJob(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
val downloadDir = storageManager.getDefaultWriteableDir()
|
||||
val applicationDirs = storageManager.getApplicationStorageDirs()
|
||||
val customDirs = settings.userSpecifiedMangaDirectories - applicationDirs
|
||||
items.value = buildList(applicationDirs.size + customDirs.size) {
|
||||
applicationDirs.mapTo(this) { dir ->
|
||||
DirectoryModel(
|
||||
title = storageManager.getDirectoryDisplayName(dir, isFullPath = false),
|
||||
titleRes = 0,
|
||||
file = dir,
|
||||
isChecked = dir == downloadDir,
|
||||
isAvailable = dir.isReadable() && dir.isWriteable(),
|
||||
isRemovable = false,
|
||||
)
|
||||
}
|
||||
customDirs.mapTo(this) { dir ->
|
||||
DirectoryModel(
|
||||
title = storageManager.getDirectoryDisplayName(dir, isFullPath = false),
|
||||
titleRes = 0,
|
||||
file = dir,
|
||||
isChecked = dir == downloadDir,
|
||||
isAvailable = dir.isReadable() && dir.isWriteable(),
|
||||
isRemovable = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun loadList() {
|
||||
val prevJob = loadingJob
|
||||
loadingJob = launchJob(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
val downloadDir = storageManager.getDefaultWriteableDir()
|
||||
val applicationDirs = storageManager.getApplicationStorageDirs()
|
||||
val customDirs = settings.userSpecifiedMangaDirectories - applicationDirs
|
||||
items.value = buildList(applicationDirs.size + customDirs.size) {
|
||||
applicationDirs.mapTo(this) { dir ->
|
||||
dir.toDirectoryModel(
|
||||
isDefault = dir == downloadDir,
|
||||
isAppPrivate = true,
|
||||
)
|
||||
}
|
||||
customDirs.mapTo(this) { dir ->
|
||||
dir.toDirectoryModel(
|
||||
isDefault = dir == downloadDir,
|
||||
isAppPrivate = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun File.toDirectoryModel(
|
||||
isDefault: Boolean,
|
||||
isAppPrivate: Boolean,
|
||||
) = DirectoryConfigModel(
|
||||
title = storageManager.getDirectoryDisplayName(this, isFullPath = false),
|
||||
path = this,
|
||||
isDefault = isDefault,
|
||||
isAccessible = isReadable() && isWriteable(),
|
||||
isAppPrivate = isAppPrivate,
|
||||
size = computeSize(),
|
||||
available = StatFs(this.absolutePath).availableBytes,
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user