Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06a0b5829b | ||
|
|
0ce2870c8b | ||
|
|
f59027666b | ||
|
|
8513bc6daf | ||
|
|
cceaefc896 | ||
|
|
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 | ||
|
|
1d1e49123a |
@@ -4,7 +4,7 @@ root = true
|
|||||||
charset = utf-8
|
charset = utf-8
|
||||||
end_of_line = lf
|
end_of_line = lf
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
indent_style = tab
|
indent_style = space
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
max_line_length = 120
|
max_line_length = 120
|
||||||
tab_width = 4
|
tab_width = 4
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,6 +6,7 @@
|
|||||||
/.idea/dictionaries
|
/.idea/dictionaries
|
||||||
/.idea/modules.xml
|
/.idea/modules.xml
|
||||||
/.idea/misc.xml
|
/.idea/misc.xml
|
||||||
|
/.idea/markdown.xml
|
||||||
/.idea/discord.xml
|
/.idea/discord.xml
|
||||||
/.idea/compiler.xml
|
/.idea/compiler.xml
|
||||||
/.idea/workspace.xml
|
/.idea/workspace.xml
|
||||||
@@ -26,4 +27,4 @@
|
|||||||
.cxx
|
.cxx
|
||||||
/.idea/deviceManager.xml
|
/.idea/deviceManager.xml
|
||||||
/.kotlin/
|
/.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">
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
<code_scheme name="Project" version="173">
|
<code_scheme name="Project" version="173">
|
||||||
<option name="OTHER_INDENT_OPTIONS">
|
<option name="OTHER_INDENT_OPTIONS">
|
||||||
<value>
|
<value />
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</value>
|
|
||||||
</option>
|
</option>
|
||||||
<AndroidXmlCodeStyleSettings>
|
<AndroidXmlCodeStyleSettings>
|
||||||
<option name="LAYOUT_SETTINGS">
|
<option name="LAYOUT_SETTINGS">
|
||||||
@@ -22,40 +20,46 @@
|
|||||||
</value>
|
</value>
|
||||||
</option>
|
</option>
|
||||||
</AndroidXmlCodeStyleSettings>
|
</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>
|
<JetCodeStyleSettings>
|
||||||
<option name="ALLOW_TRAILING_COMMA" value="true" />
|
<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" />
|
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||||
</JetCodeStyleSettings>
|
</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">
|
<codeStyleSettings language="Shell Script">
|
||||||
<indentOptions>
|
<indentOptions>
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
@@ -64,7 +68,6 @@
|
|||||||
<codeStyleSettings language="XML">
|
<codeStyleSettings language="XML">
|
||||||
<indentOptions>
|
<indentOptions>
|
||||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</indentOptions>
|
</indentOptions>
|
||||||
<arrangement>
|
<arrangement>
|
||||||
<rules>
|
<rules>
|
||||||
@@ -179,9 +182,6 @@
|
|||||||
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
|
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
|
||||||
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
|
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
|
||||||
<option name="LINE_COMMENT_ADD_SPACE" value="true" />
|
<option name="LINE_COMMENT_ADD_SPACE" value="true" />
|
||||||
<indentOptions>
|
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
</code_scheme>
|
</code_scheme>
|
||||||
</component>
|
</component>
|
||||||
4
.idea/gradle.xml
generated
4
.idea/gradle.xml
generated
@@ -6,7 +6,7 @@
|
|||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="jbr-21" />
|
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
@@ -16,4 +16,4 @@
|
|||||||
</GradleProjectSettings>
|
</GradleProjectSettings>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
||||||
* Password / fingerprint-protected access to the app
|
* Password / fingerprint-protected access to the app
|
||||||
* Automatically sync app data with other devices on the same account
|
* 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>
|
</div>
|
||||||
|
|
||||||
@@ -112,6 +112,6 @@ You may copy, distribute and modify the software as long as you track changes/da
|
|||||||
|
|
||||||
<div align="left">
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ android {
|
|||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdk = 23
|
minSdk = 23
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1030
|
versionCode = 1033
|
||||||
versionName = '9.2'
|
versionName = '9.4.1'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||||
ksp {
|
ksp {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
android:fullBackupContent="@xml/backup_content"
|
android:fullBackupContent="@xml/backup_content"
|
||||||
android:fullBackupOnly="true"
|
android:fullBackupOnly="true"
|
||||||
android:hasFragileUserData="true"
|
android:hasFragileUserData="true"
|
||||||
|
android:restoreAnyVersion="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:largeHeap="true"
|
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.FavouriteBackup
|
||||||
import org.koitharu.kotatsu.backups.data.model.HistoryBackup
|
import org.koitharu.kotatsu.backups.data.model.HistoryBackup
|
||||||
import org.koitharu.kotatsu.backups.data.model.MangaBackup
|
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.SourceBackup
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.StatisticBackup
|
||||||
import org.koitharu.kotatsu.backups.domain.BackupSection
|
import org.koitharu.kotatsu.backups.domain.BackupSection
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.util.CompositeResult
|
import org.koitharu.kotatsu.core.util.CompositeResult
|
||||||
import org.koitharu.kotatsu.core.util.progress.Progress
|
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.parsers.util.runCatchingCancellable
|
||||||
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
@@ -43,220 +48,267 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
@Reusable
|
@Reusable
|
||||||
class BackupRepository @Inject constructor(
|
class BackupRepository @Inject constructor(
|
||||||
private val database: MangaDatabase,
|
private val database: MangaDatabase,
|
||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
private val tapGridSettings: TapGridSettings,
|
private val tapGridSettings: TapGridSettings,
|
||||||
|
private val mangaSourcesRepository: MangaSourcesRepository,
|
||||||
|
private val savedFiltersRepository: SavedFiltersRepository,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val json = Json {
|
private val json = Json {
|
||||||
allowSpecialFloatingPointValues = true
|
allowSpecialFloatingPointValues = true
|
||||||
coerceInputValues = true
|
coerceInputValues = true
|
||||||
encodeDefaults = true
|
encodeDefaults = true
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
useAlternativeNames = false
|
useAlternativeNames = false
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createBackup(
|
suspend fun createBackup(
|
||||||
output: ZipOutputStream,
|
output: ZipOutputStream,
|
||||||
progress: FlowCollector<Progress>?,
|
progress: FlowCollector<Progress>?,
|
||||||
) {
|
) {
|
||||||
progress?.emit(Progress.INDETERMINATE)
|
progress?.emit(Progress.INDETERMINATE)
|
||||||
var commonProgress = Progress(0, BackupSection.entries.size)
|
var commonProgress = Progress(0, BackupSection.entries.size)
|
||||||
for (section in BackupSection.entries) {
|
for (section in BackupSection.entries) {
|
||||||
when (section) {
|
when (section) {
|
||||||
BackupSection.INDEX -> output.writeJsonArray(
|
BackupSection.INDEX -> output.writeJsonArray(
|
||||||
section = BackupSection.INDEX,
|
section = BackupSection.INDEX,
|
||||||
data = flowOf(BackupIndex()),
|
data = flowOf(BackupIndex()),
|
||||||
serializer = serializer(),
|
serializer = serializer(),
|
||||||
)
|
)
|
||||||
|
|
||||||
BackupSection.HISTORY -> output.writeJsonArray(
|
BackupSection.HISTORY -> output.writeJsonArray(
|
||||||
section = BackupSection.HISTORY,
|
section = BackupSection.HISTORY,
|
||||||
data = database.getHistoryDao().dump().map { HistoryBackup(it) },
|
data = database.getHistoryDao().dump().map { HistoryBackup(it) },
|
||||||
serializer = serializer(),
|
serializer = serializer(),
|
||||||
)
|
)
|
||||||
|
|
||||||
BackupSection.CATEGORIES -> output.writeJsonArray(
|
BackupSection.CATEGORIES -> output.writeJsonArray(
|
||||||
section = BackupSection.CATEGORIES,
|
section = BackupSection.CATEGORIES,
|
||||||
data = database.getFavouriteCategoriesDao().findAll().asFlow().map { CategoryBackup(it) },
|
data = database.getFavouriteCategoriesDao().findAll().asFlow().map { CategoryBackup(it) },
|
||||||
serializer = serializer(),
|
serializer = serializer(),
|
||||||
)
|
)
|
||||||
|
|
||||||
BackupSection.FAVOURITES -> output.writeJsonArray(
|
BackupSection.FAVOURITES -> output.writeJsonArray(
|
||||||
section = BackupSection.FAVOURITES,
|
section = BackupSection.FAVOURITES,
|
||||||
data = database.getFavouritesDao().dump().map { FavouriteBackup(it) },
|
data = database.getFavouritesDao().dump().map { FavouriteBackup(it) },
|
||||||
serializer = serializer(),
|
serializer = serializer(),
|
||||||
)
|
)
|
||||||
|
|
||||||
BackupSection.SETTINGS -> output.writeString(
|
BackupSection.SETTINGS -> output.writeString(
|
||||||
section = BackupSection.SETTINGS,
|
section = BackupSection.SETTINGS,
|
||||||
data = dumpSettings(),
|
data = dumpSettings(),
|
||||||
)
|
)
|
||||||
|
|
||||||
BackupSection.SETTINGS_READER_GRID -> output.writeString(
|
BackupSection.SETTINGS_READER_GRID -> output.writeString(
|
||||||
section = BackupSection.SETTINGS_READER_GRID,
|
section = BackupSection.SETTINGS_READER_GRID,
|
||||||
data = dumpReaderGridSettings(),
|
data = dumpReaderGridSettings(),
|
||||||
)
|
)
|
||||||
|
|
||||||
BackupSection.BOOKMARKS -> output.writeJsonArray(
|
BackupSection.BOOKMARKS -> output.writeJsonArray(
|
||||||
section = BackupSection.BOOKMARKS,
|
section = BackupSection.BOOKMARKS,
|
||||||
data = database.getBookmarksDao().dump().map { BookmarkBackup(it.first, it.second) },
|
data = database.getBookmarksDao().dump().map { BookmarkBackup(it.first, it.second) },
|
||||||
serializer = serializer(),
|
serializer = serializer(),
|
||||||
)
|
)
|
||||||
|
|
||||||
BackupSection.SOURCES -> output.writeJsonArray(
|
BackupSection.SOURCES -> output.writeJsonArray(
|
||||||
section = BackupSection.SOURCES,
|
section = BackupSection.SOURCES,
|
||||||
data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) },
|
data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) },
|
||||||
serializer = serializer(),
|
serializer = serializer(),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
progress?.emit(commonProgress)
|
|
||||||
commonProgress++
|
|
||||||
}
|
|
||||||
progress?.emit(commonProgress)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreBackup(
|
BackupSection.SCROBBLING -> output.writeJsonArray(
|
||||||
input: ZipInputStream,
|
section = BackupSection.SCROBBLING,
|
||||||
sections: Set<BackupSection>,
|
data = database.getScrobblingDao().dumpEnabled().map { ScrobblingBackup(it) },
|
||||||
progress: FlowCollector<Progress>?,
|
serializer = serializer(),
|
||||||
): 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.CATEGORIES -> input.readJsonArray<CategoryBackup>(serializer()).restoreToDb {
|
BackupSection.STATS -> output.writeJsonArray(
|
||||||
getFavouriteCategoriesDao().upsert(it.toEntity())
|
section = BackupSection.STATS,
|
||||||
}
|
data = database.getStatsDao().dumpEnabled().map { StatisticBackup(it) },
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
BackupSection.FAVOURITES -> input.readJsonArray<FavouriteBackup>(serializer()).restoreToDb {
|
BackupSection.SAVED_FILTERS -> {
|
||||||
upsertManga(it.manga)
|
val sources = mangaSourcesRepository.getEnabledSources()
|
||||||
getFavouritesDao().upsert(it.toEntity())
|
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 {
|
suspend fun restoreBackup(
|
||||||
settings.upsertAll(it)
|
input: ZipInputStream,
|
||||||
CompositeResult.success()
|
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 {
|
BackupSection.CATEGORIES -> input.readJsonArray<CategoryBackup>(serializer()).restoreToDb {
|
||||||
tapGridSettings.upsertAll(it)
|
getFavouriteCategoriesDao().upsert(it.toEntity())
|
||||||
CompositeResult.success()
|
}
|
||||||
}
|
|
||||||
|
|
||||||
BackupSection.BOOKMARKS -> input.readJsonArray<BookmarkBackup>(serializer()).restoreToDb {
|
BackupSection.FAVOURITES -> input.readJsonArray<FavouriteBackup>(serializer()).restoreToDb {
|
||||||
upsertManga(it.manga)
|
upsertManga(it.manga)
|
||||||
getBookmarksDao().upsert(it.bookmarks.map { b -> b.toEntity() })
|
getFavouritesDao().upsert(it.toEntity())
|
||||||
}
|
}
|
||||||
|
|
||||||
BackupSection.SOURCES -> input.readJsonArray<SourceBackup>(serializer()).restoreToDb {
|
BackupSection.SETTINGS -> input.readMap().let {
|
||||||
getSourcesDao().upsert(it.toEntity())
|
settings.upsertAll(it)
|
||||||
}
|
CompositeResult.success()
|
||||||
|
}
|
||||||
|
|
||||||
null -> CompositeResult.EMPTY // skip unknown entries
|
BackupSection.SETTINGS_READER_GRID -> input.readMap().let {
|
||||||
}
|
tapGridSettings.upsertAll(it)
|
||||||
progress?.emit(commonProgress)
|
CompositeResult.success()
|
||||||
commonProgress++
|
}
|
||||||
}
|
|
||||||
input.closeEntry()
|
|
||||||
entry = input.nextEntry
|
|
||||||
}
|
|
||||||
progress?.emit(commonProgress)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun <T> ZipOutputStream.writeJsonArray(
|
BackupSection.BOOKMARKS -> input.readJsonArray<BookmarkBackup>(serializer()).restoreToDb {
|
||||||
section: BackupSection,
|
upsertManga(it.manga)
|
||||||
data: Flow<T>,
|
getBookmarksDao().upsert(it.bookmarks.map { b -> b.toEntity() })
|
||||||
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 <T> InputStream.readJsonArray(
|
BackupSection.SOURCES -> input.readJsonArray<SourceBackup>(serializer()).restoreToDb {
|
||||||
serializer: DeserializationStrategy<T>,
|
getSourcesDao().upsert(it.toEntity())
|
||||||
): Sequence<T> = json.decodeToSequence(this, serializer, DecodeSequenceMode.ARRAY_WRAPPED)
|
}
|
||||||
|
|
||||||
private fun InputStream.readMap(): Map<String, Any?> {
|
BackupSection.SCROBBLING -> input.readJsonArray<ScrobblingBackup>(serializer()).restoreToDb {
|
||||||
val jo = JSONArray(readString()).getJSONObject(0)
|
getScrobblingDao().upsert(it.toEntity())
|
||||||
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 fun ZipOutputStream.writeString(
|
BackupSection.STATS -> input.readJsonArray<StatisticBackup>(serializer()).restoreToDb {
|
||||||
section: BackupSection,
|
getStatsDao().upsert(it.toEntity())
|
||||||
data: String,
|
}
|
||||||
) {
|
|
||||||
putNextEntry(ZipEntry(section.entryName))
|
|
||||||
try {
|
|
||||||
write("[")
|
|
||||||
write(data)
|
|
||||||
write("]")
|
|
||||||
} finally {
|
|
||||||
closeEntry()
|
|
||||||
flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
private suspend fun <T> ZipOutputStream.writeJsonArray(
|
||||||
val map = settings.getAllValues().toMutableMap()
|
section: BackupSection,
|
||||||
map.remove(AppSettings.KEY_APP_PASSWORD)
|
data: Flow<T>,
|
||||||
map.remove(AppSettings.KEY_PROXY_PASSWORD)
|
serializer: SerializationStrategy<T>,
|
||||||
map.remove(AppSettings.KEY_PROXY_LOGIN)
|
) {
|
||||||
map.remove(AppSettings.KEY_INCOGNITO_MODE)
|
data.onStart {
|
||||||
return JSONObject(map).toString()
|
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 {
|
private fun <T> InputStream.readJsonArray(
|
||||||
return JSONObject(tapGridSettings.getAllValues()).toString()
|
serializer: DeserializationStrategy<T>,
|
||||||
}
|
): Sequence<T> = json.decodeToSequence(this, serializer, DecodeSequenceMode.ARRAY_WRAPPED)
|
||||||
|
|
||||||
private suspend fun MangaDatabase.upsertManga(manga: MangaBackup) {
|
private fun InputStream.readMap(): Map<String, Any?> {
|
||||||
val tags = manga.tags.map { it.toEntity() }
|
val jo = JSONArray(readString()).getJSONObject(0)
|
||||||
getTagsDao().upsert(tags)
|
val map = ArrayMap<String, Any?>(jo.length())
|
||||||
getMangaDao().upsert(manga.toEntity(), tags)
|
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 {
|
private fun ZipOutputStream.writeString(
|
||||||
return fold(CompositeResult.EMPTY) { result, item ->
|
section: BackupSection,
|
||||||
result + runCatchingCancellable {
|
data: String,
|
||||||
database.withTransaction {
|
) {
|
||||||
database.block(item)
|
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.backups.data.BackupRepository
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
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 org.koitharu.kotatsu.reader.data.TapGridSettings
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileDescriptor
|
import java.io.FileDescriptor
|
||||||
@@ -36,15 +38,22 @@ class AppBackupAgent : BackupAgent() {
|
|||||||
|
|
||||||
override fun onFullBackup(data: FullBackupDataOutput) {
|
override fun onFullBackup(data: FullBackupDataOutput) {
|
||||||
super.onFullBackup(data)
|
super.onFullBackup(data)
|
||||||
val file =
|
val file = createBackupFile(
|
||||||
createBackupFile(
|
this,
|
||||||
this,
|
BackupRepository(
|
||||||
BackupRepository(
|
database = MangaDatabase(context = applicationContext),
|
||||||
MangaDatabase(context = applicationContext),
|
settings = AppSettings(applicationContext),
|
||||||
AppSettings(applicationContext),
|
tapGridSettings = TapGridSettings(applicationContext),
|
||||||
TapGridSettings(applicationContext),
|
mangaSourcesRepository = MangaSourcesRepository(
|
||||||
|
context = applicationContext,
|
||||||
|
db = MangaDatabase(context = applicationContext),
|
||||||
|
settings = AppSettings(applicationContext),
|
||||||
),
|
),
|
||||||
)
|
savedFiltersRepository = SavedFiltersRepository(
|
||||||
|
context = applicationContext,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
try {
|
try {
|
||||||
fullBackupFile(file, data)
|
fullBackupFile(file, data)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -68,6 +77,14 @@ class AppBackupAgent : BackupAgent() {
|
|||||||
database = MangaDatabase(applicationContext),
|
database = MangaDatabase(applicationContext),
|
||||||
settings = AppSettings(applicationContext),
|
settings = AppSettings(applicationContext),
|
||||||
tapGridSettings = TapGridSettings(applicationContext),
|
tapGridSettings = TapGridSettings(applicationContext),
|
||||||
|
mangaSourcesRepository = MangaSourcesRepository(
|
||||||
|
context = applicationContext,
|
||||||
|
db = MangaDatabase(context = applicationContext),
|
||||||
|
settings = AppSettings(applicationContext),
|
||||||
|
),
|
||||||
|
savedFiltersRepository = SavedFiltersRepository(
|
||||||
|
context = applicationContext,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
destination.delete()
|
destination.delete()
|
||||||
@@ -90,8 +107,12 @@ class AppBackupAgent : BackupAgent() {
|
|||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) {
|
fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) {
|
||||||
ZipInputStream(ByteStreams.limit(FileInputStream(fd), size)).use { input ->
|
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 {
|
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"),
|
SETTINGS_READER_GRID("reader_grid"),
|
||||||
BOOKMARKS("bookmarks"),
|
BOOKMARKS("bookmarks"),
|
||||||
SOURCES("sources"),
|
SOURCES("sources"),
|
||||||
|
SCROBBLING("scrobbling"),
|
||||||
|
STATS("statistics"),
|
||||||
|
SAVED_FILTERS("saved_filters"),
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun of(entry: ZipEntry): BackupSection? {
|
fun of(entry: ZipEntry): BackupSection? {
|
||||||
val name = entry.name.lowercase(Locale.ROOT)
|
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) {
|
suspend fun uploadBackup(file: File) {
|
||||||
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
|
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
|
||||||
val multipartBody = MultipartBody.Builder()
|
val multipartBody = MultipartBody.Builder()
|
||||||
.setType(MultipartBody.Companion.FORM)
|
.setType(MultipartBody.FORM)
|
||||||
.addFormDataPart("chat_id", requireChatId())
|
.addFormDataPart("chat_id", requireChatId())
|
||||||
.addFormDataPart("document", file.name, requestBody)
|
.addFormDataPart("document", file.name, requestBody)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ data class BackupSectionModel(
|
|||||||
BackupSection.SETTINGS_READER_GRID -> R.string.reader_actions
|
BackupSection.SETTINGS_READER_GRID -> R.string.reader_actions
|
||||||
BackupSection.BOOKMARKS -> R.string.bookmarks
|
BackupSection.BOOKMARKS -> R.string.bookmarks
|
||||||
BackupSection.SOURCES -> R.string.remote_sources
|
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 {
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import androidx.appcompat.app.AppCompatDelegate
|
|||||||
import androidx.hilt.work.HiltWorkerFactory
|
import androidx.hilt.work.HiltWorkerFactory
|
||||||
import androidx.room.InvalidationTracker
|
import androidx.room.InvalidationTracker
|
||||||
import androidx.work.Configuration
|
import androidx.work.Configuration
|
||||||
import androidx.work.WorkManager
|
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
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.AppValidator
|
||||||
import org.koitharu.kotatsu.core.os.RomCompat
|
import org.koitharu.kotatsu.core.os.RomCompat
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
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.core.util.ext.processLifecycleScope
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||||
@@ -63,9 +61,6 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var workScheduleManager: WorkScheduleManager
|
lateinit var workScheduleManager: WorkScheduleManager
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var workManagerProvider: Provider<WorkManager>
|
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>
|
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>
|
||||||
|
|
||||||
@@ -99,7 +94,6 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
localStorageChanges.collect(localMangaIndexProvider.get())
|
localStorageChanges.collect(localMangaIndexProvider.get())
|
||||||
}
|
}
|
||||||
workScheduleManager.init()
|
workScheduleManager.init()
|
||||||
WorkServiceStopHelper(workManagerProvider).setup()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun attachBaseContext(base: Context) {
|
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")
|
@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>
|
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
|
@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")
|
@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>
|
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.Fragment
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import androidx.fragment.app.FragmentManager
|
import androidx.fragment.app.FragmentManager
|
||||||
import dagger.assisted.Assisted
|
import androidx.lifecycle.Lifecycle
|
||||||
import dagger.assisted.AssistedFactory
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import dagger.assisted.AssistedInject
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
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.InteractiveActionRequiredException
|
||||||
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
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.ui.dialog.buildAlertDialog
|
||||||
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
||||||
import org.koitharu.kotatsu.core.util.ext.restartApplication
|
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.AuthRequiredException
|
||||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
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.scrobbling.common.ui.ScrobblerAuthHelper
|
||||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||||
import java.security.cert.CertPathValidatorException
|
import java.security.cert.CertPathValidatorException
|
||||||
|
import javax.inject.Inject
|
||||||
import javax.inject.Provider
|
import javax.inject.Provider
|
||||||
import javax.net.ssl.SSLException
|
import javax.net.ssl.SSLException
|
||||||
import kotlin.coroutines.Continuation
|
import kotlin.coroutines.Continuation
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
class ExceptionResolver @AssistedInject constructor(
|
class ExceptionResolver private constructor(
|
||||||
@Assisted private val host: Host,
|
private val host: Host,
|
||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
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()) {
|
private val browserActionContract = host.registerForActivityResult(BrowserActivity.Contract()) {
|
||||||
handleActivityResult(BrowserActivity.TAG, true)
|
handleActivityResult(BrowserActivity.TAG, true)
|
||||||
}
|
}
|
||||||
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
|
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
|
||||||
handleActivityResult(SourceAuthActivity.TAG, it)
|
handleActivityResult(SourceAuthActivity.TAG, it)
|
||||||
}
|
}
|
||||||
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
|
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
|
||||||
handleActivityResult(CloudFlareActivity.TAG, it)
|
handleActivityResult(CloudFlareActivity.TAG, it)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showErrorDetails(e: Throwable, url: String? = null) {
|
fun showErrorDetails(e: Throwable, url: String? = null) {
|
||||||
host.router()?.showErrorDialog(e, url)
|
host.router.showErrorDialog(e, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
suspend fun resolve(e: Throwable): Boolean = host.lifecycleScope.async {
|
||||||
is CloudFlareProtectedException -> resolveCF(e)
|
when (e) {
|
||||||
is AuthRequiredException -> resolveAuthException(e.source)
|
is CloudFlareProtectedException -> resolveCF(e)
|
||||||
is SSLException,
|
is AuthRequiredException -> resolveAuthException(e.source)
|
||||||
is CertPathValidatorException -> {
|
is SSLException,
|
||||||
showSslErrorDialog()
|
is CertPathValidatorException -> {
|
||||||
false
|
showSslErrorDialog()
|
||||||
}
|
false
|
||||||
|
}
|
||||||
|
|
||||||
is InteractiveActionRequiredException -> resolveBrowserAction(e)
|
is InteractiveActionRequiredException -> resolveBrowserAction(e)
|
||||||
|
|
||||||
is ProxyConfigException -> {
|
is ProxyConfigException -> {
|
||||||
host.router()?.openProxySettings()
|
host.router.openProxySettings()
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
is NotFoundException -> {
|
is NotFoundException -> {
|
||||||
openInBrowser(e.url)
|
openInBrowser(e.url)
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
is UnsupportedSourceException -> {
|
is EmptyMangaException -> {
|
||||||
e.manga?.let { openAlternatives(it) }
|
when (e.reason) {
|
||||||
false
|
EmptyMangaReason.NO_CHAPTERS -> openAlternatives(e.manga)
|
||||||
}
|
EmptyMangaReason.LOADING_ERROR -> Unit
|
||||||
|
EmptyMangaReason.RESTRICTED -> host.router.openBrowser(e.manga)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
is ScrobblerAuthRequiredException -> {
|
is UnsupportedSourceException -> {
|
||||||
val authHelper = scrobblerAuthHelperProvider.get()
|
e.manga?.let { openAlternatives(it) }
|
||||||
if (authHelper.isAuthorized(e.scrobbler)) {
|
false
|
||||||
true
|
}
|
||||||
} else {
|
|
||||||
host.withContext {
|
|
||||||
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
|
|
||||||
}
|
|
||||||
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(
|
else -> false
|
||||||
e: InteractiveActionRequiredException
|
}
|
||||||
): Boolean = suspendCoroutine { cont ->
|
}.await()
|
||||||
continuations[BrowserActivity.TAG] = cont
|
|
||||||
browserActionContract.launch(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
|
private suspend fun resolveBrowserAction(
|
||||||
continuations[CloudFlareActivity.TAG] = cont
|
e: InteractiveActionRequiredException
|
||||||
cloudflareContract.launch(e)
|
): Boolean = suspendCoroutine { cont ->
|
||||||
}
|
continuations[BrowserActivity.TAG] = cont
|
||||||
|
browserActionContract.launch(e)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
|
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
|
||||||
continuations[SourceAuthActivity.TAG] = cont
|
continuations[CloudFlareActivity.TAG] = cont
|
||||||
sourceAuthContract.launch(source)
|
cloudflareContract.launch(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openInBrowser(url: String) {
|
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
|
||||||
host.router()?.openBrowser(url, null, null)
|
continuations[SourceAuthActivity.TAG] = cont
|
||||||
}
|
sourceAuthContract.launch(source)
|
||||||
|
}
|
||||||
|
|
||||||
private fun openAlternatives(manga: Manga) {
|
private fun openInBrowser(url: String) {
|
||||||
host.router()?.openAlternatives(manga)
|
host.router.openBrowser(url, null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleActivityResult(tag: String, result: Boolean) {
|
private fun openAlternatives(manga: Manga) {
|
||||||
continuations.remove(tag)?.resume(result)
|
host.router.openAlternatives(manga)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showSslErrorDialog() {
|
private fun handleActivityResult(tag: String, result: Boolean) {
|
||||||
val ctx = host.getContext() ?: return
|
continuations.remove(tag)?.resume(result)
|
||||||
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 inline fun Host.withContext(block: Context.() -> Unit) {
|
private fun showSslErrorDialog() {
|
||||||
getContext()?.apply(block)
|
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) {
|
class Factory @Inject constructor(
|
||||||
is FragmentActivity -> router
|
private val settings: AppSettings,
|
||||||
is Fragment -> router
|
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
||||||
else -> null
|
) {
|
||||||
}
|
|
||||||
|
|
||||||
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
|
val context: Context?
|
||||||
interface Factory {
|
|
||||||
|
|
||||||
fun create(host: Host): ExceptionResolver
|
val router: AppRouter
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
val fragmentManager: FragmentManager
|
||||||
|
|
||||||
@StringRes
|
inline fun withContext(block: Context.() -> Unit) {
|
||||||
fun getResolveStringId(e: Throwable) = when (e) {
|
context?.apply(block)
|
||||||
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
|
class ActivityHost(val activity: FragmentActivity) : Host,
|
||||||
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
ActivityResultCaller by activity,
|
||||||
is SSLException,
|
LifecycleOwner by activity {
|
||||||
is CertPathValidatorException -> R.string.fix
|
|
||||||
|
|
||||||
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
|
package org.koitharu.kotatsu.core.network.webview
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.AndroidRuntimeException
|
||||||
import android.webkit.WebSettings
|
import android.webkit.WebSettings
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
@@ -41,7 +42,13 @@ class WebViewExecutor @Inject constructor(
|
|||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
val defaultUserAgent: String? by lazy {
|
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 {
|
suspend fun evaluateJs(baseUrl: String?, script: String): String? = mutex.withLock {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import coil3.request.Options
|
|||||||
import coil3.size.pxOrElse
|
import coil3.size.pxOrElse
|
||||||
import coil3.toAndroidUri
|
import coil3.toAndroidUri
|
||||||
import coil3.toBitmap
|
import coil3.toBitmap
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import okio.FileSystem
|
import okio.FileSystem
|
||||||
@@ -41,7 +42,6 @@ import org.koitharu.kotatsu.local.data.LocalStorageCache
|
|||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.coroutines.coroutineContext
|
|
||||||
import coil3.Uri as CoilUri
|
import coil3.Uri as CoilUri
|
||||||
|
|
||||||
class FaviconFetcher(
|
class FaviconFetcher(
|
||||||
@@ -88,7 +88,7 @@ class FaviconFetcher(
|
|||||||
var favicons = repository.getFavicons()
|
var favicons = repository.getFavicons()
|
||||||
var lastError: Exception? = null
|
var lastError: Exception? = null
|
||||||
while (favicons.isNotEmpty()) {
|
while (favicons.isNotEmpty()) {
|
||||||
coroutineContext.ensureActive()
|
currentCoroutineContext().ensureActive()
|
||||||
val icon = favicons.find(sizePx) ?: throwNSEE(lastError)
|
val icon = favicons.find(sizePx) ?: throwNSEE(lastError)
|
||||||
try {
|
try {
|
||||||
val result = imageLoader.fetch(icon.url, options)
|
val result = imageLoader.fetch(icon.url, options)
|
||||||
|
|||||||
@@ -138,6 +138,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
|
get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
|
||||||
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
|
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
|
||||||
|
|
||||||
|
@get:FloatRange(0.0, 1.0)
|
||||||
|
var readerDoublePagesSensitivity: Float
|
||||||
|
get() = prefs.getFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, 0.5f)
|
||||||
|
set(@FloatRange(0.0, 1.0) value) = prefs.edit { putFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, value) }
|
||||||
|
|
||||||
val readerScreenOrientation: Int
|
val readerScreenOrientation: Int
|
||||||
get() = prefs.getString(KEY_READER_ORIENTATION, null)?.toIntOrNull()
|
get() = prefs.getString(KEY_READER_ORIENTATION, null)?.toIntOrNull()
|
||||||
?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||||
@@ -404,6 +409,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
val isReaderBarTransparent: Boolean
|
val isReaderBarTransparent: Boolean
|
||||||
get() = prefs.getBoolean(KEY_READER_BAR_TRANSPARENT, true)
|
get() = prefs.getBoolean(KEY_READER_BAR_TRANSPARENT, true)
|
||||||
|
|
||||||
|
val isReaderChapterToastEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_READER_CHAPTER_TOAST, true)
|
||||||
|
|
||||||
val isReaderKeepScreenOn: Boolean
|
val isReaderKeepScreenOn: Boolean
|
||||||
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
|
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
|
||||||
|
|
||||||
@@ -538,11 +546,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
val isPeriodicalBackupEnabled: Boolean
|
val isPeriodicalBackupEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false)
|
get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false)
|
||||||
|
|
||||||
val periodicalBackupFrequency: Long
|
val periodicalBackupFrequency: Float
|
||||||
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L
|
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toFloatOrNull() ?: 7f
|
||||||
|
|
||||||
val periodicalBackupFrequencyMillis: Long
|
val periodicalBackupFrequencyMillis: Long
|
||||||
get() = TimeUnit.DAYS.toMillis(periodicalBackupFrequency)
|
get() = (TimeUnit.DAYS.toMillis(1) * periodicalBackupFrequency).toLong()
|
||||||
|
|
||||||
val periodicalBackupMaxCount: Int
|
val periodicalBackupMaxCount: Int
|
||||||
get() = if (prefs.getBoolean(KEY_BACKUP_PERIODICAL_TRIM, true)) {
|
get() = if (prefs.getBoolean(KEY_BACKUP_PERIODICAL_TRIM, true)) {
|
||||||
@@ -673,6 +681,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_REMOTE_SOURCES = "remote_sources"
|
const val KEY_REMOTE_SOURCES = "remote_sources"
|
||||||
const val KEY_LOCAL_STORAGE = "local_storage"
|
const val KEY_LOCAL_STORAGE = "local_storage"
|
||||||
const val KEY_READER_DOUBLE_PAGES = "reader_double_pages"
|
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_ZOOM_BUTTONS = "reader_zoom_buttons"
|
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
|
||||||
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
|
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
|
||||||
const val KEY_READER_NAVIGATION_INVERTED = "reader_navigation_inverted"
|
const val KEY_READER_NAVIGATION_INVERTED = "reader_navigation_inverted"
|
||||||
@@ -741,6 +750,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_SYNC_SETTINGS = "sync_settings"
|
const val KEY_SYNC_SETTINGS = "sync_settings"
|
||||||
const val KEY_READER_BAR = "reader_bar"
|
const val KEY_READER_BAR = "reader_bar"
|
||||||
const val KEY_READER_BAR_TRANSPARENT = "reader_bar_transparent"
|
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_BACKGROUND = "reader_background"
|
||||||
const val KEY_READER_SCREEN_ON = "reader_screen_on"
|
const val KEY_READER_SCREEN_ON = "reader_screen_on"
|
||||||
const val KEY_SHORTCUTS = "dynamic_shortcuts"
|
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.ifNullOrEmpty
|
||||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||||
import org.koitharu.kotatsu.settings.utils.validation.DomainValidator
|
import org.koitharu.kotatsu.settings.utils.validation.DomainValidator
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
|
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?
|
var defaultSortOrder: SortOrder?
|
||||||
get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java)
|
get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java)
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import androidx.appcompat.R as appcompatR
|
|||||||
|
|
||||||
abstract class BaseActivity<B : ViewBinding> :
|
abstract class BaseActivity<B : ViewBinding> :
|
||||||
AppCompatActivity(),
|
AppCompatActivity(),
|
||||||
ExceptionResolver.Host,
|
|
||||||
OnApplyWindowInsetsListener,
|
OnApplyWindowInsetsListener,
|
||||||
ScreenshotPolicyHelper.ContentContainer {
|
ScreenshotPolicyHelper.ContentContainer {
|
||||||
|
|
||||||
@@ -87,10 +86,6 @@ abstract class BaseActivity<B : ViewBinding> :
|
|||||||
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
|
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
|
||||||
override fun setContentView(view: View?) = throw UnsupportedOperationException()
|
override fun setContentView(view: View?) = throw UnsupportedOperationException()
|
||||||
|
|
||||||
override fun getContext() = this
|
|
||||||
|
|
||||||
override fun getChildFragmentManager(): FragmentManager = supportFragmentManager
|
|
||||||
|
|
||||||
protected fun setContentView(binding: B) {
|
protected fun setContentView(binding: B) {
|
||||||
this.viewBinding = binding
|
this.viewBinding = binding
|
||||||
super.setContentView(binding.root)
|
super.setContentView(binding.root)
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
|||||||
|
|
||||||
abstract class BaseFragment<B : ViewBinding> :
|
abstract class BaseFragment<B : ViewBinding> :
|
||||||
OnApplyWindowInsetsListener,
|
OnApplyWindowInsetsListener,
|
||||||
Fragment(),
|
Fragment() {
|
||||||
ExceptionResolver.Host {
|
|
||||||
|
|
||||||
var viewBinding: B? = null
|
var viewBinding: B? = null
|
||||||
private set
|
private set
|
||||||
|
|||||||
@@ -36,8 +36,7 @@ import com.google.android.material.R as materialR
|
|||||||
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||||
PreferenceFragmentCompat(),
|
PreferenceFragmentCompat(),
|
||||||
OnApplyWindowInsetsListener,
|
OnApplyWindowInsetsListener,
|
||||||
RecyclerViewOwner,
|
RecyclerViewOwner {
|
||||||
ExceptionResolver.Host {
|
|
||||||
|
|
||||||
protected lateinit var exceptionResolver: ExceptionResolver
|
protected lateinit var exceptionResolver: ExceptionResolver
|
||||||
private set
|
private set
|
||||||
|
|||||||
@@ -2,10 +2,17 @@ package org.koitharu.kotatsu.core.ui.dialog
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.view.LayoutInflater
|
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.CompoundButton.OnCheckedChangeListener
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.FrameLayout
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.annotation.UiContext
|
import androidx.annotation.UiContext
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.appcompat.widget.AppCompatEditText
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
@@ -15,54 +22,103 @@ import com.hannesdorfmann.adapterdelegates4.AdapterDelegatesManager
|
|||||||
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
|
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
|
import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
|
||||||
|
import org.koitharu.kotatsu.databinding.ViewDialogAutocompleteBinding
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
inline fun buildAlertDialog(
|
inline fun buildAlertDialog(
|
||||||
@UiContext context: Context,
|
@UiContext context: Context,
|
||||||
isCentered: Boolean = false,
|
isCentered: Boolean = false,
|
||||||
block: MaterialAlertDialogBuilder.() -> Unit,
|
block: MaterialAlertDialogBuilder.() -> Unit,
|
||||||
): AlertDialog = MaterialAlertDialogBuilder(
|
): AlertDialog = MaterialAlertDialogBuilder(
|
||||||
context,
|
context,
|
||||||
if (isCentered) materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered else 0,
|
if (isCentered) materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered else 0,
|
||||||
).apply(block).create()
|
).apply(block).create()
|
||||||
|
|
||||||
fun <B : AlertDialog.Builder> B.setCheckbox(
|
fun <B : AlertDialog.Builder> B.setCheckbox(
|
||||||
@StringRes textResId: Int,
|
@StringRes textResId: Int,
|
||||||
isChecked: Boolean,
|
isChecked: Boolean,
|
||||||
onCheckedChangeListener: OnCheckedChangeListener
|
onCheckedChangeListener: OnCheckedChangeListener
|
||||||
) = apply {
|
) = apply {
|
||||||
val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
|
val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
|
||||||
binding.checkbox.setText(textResId)
|
binding.checkbox.setText(textResId)
|
||||||
binding.checkbox.isChecked = isChecked
|
binding.checkbox.isChecked = isChecked
|
||||||
binding.checkbox.setOnCheckedChangeListener(onCheckedChangeListener)
|
binding.checkbox.setOnCheckedChangeListener(onCheckedChangeListener)
|
||||||
setView(binding.root)
|
setView(binding.root)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
|
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
|
||||||
list: List<T>,
|
list: List<T>,
|
||||||
delegate: AdapterDelegate<List<T>>,
|
delegate: AdapterDelegate<List<T>>,
|
||||||
) = apply {
|
) = apply {
|
||||||
val delegatesManager = AdapterDelegatesManager<List<T>>()
|
val delegatesManager = AdapterDelegatesManager<List<T>>()
|
||||||
delegatesManager.addDelegate(delegate)
|
delegatesManager.addDelegate(delegate)
|
||||||
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
|
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
|
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
|
||||||
list: List<T>,
|
list: List<T>,
|
||||||
vararg delegates: AdapterDelegate<List<T>>,
|
vararg delegates: AdapterDelegate<List<T>>,
|
||||||
) = apply {
|
) = apply {
|
||||||
val delegatesManager = AdapterDelegatesManager<List<T>>()
|
val delegatesManager = AdapterDelegatesManager<List<T>>()
|
||||||
delegates.forEach { delegatesManager.addDelegate(it) }
|
delegates.forEach { delegatesManager.addDelegate(it) }
|
||||||
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
|
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <B : AlertDialog.Builder> B.setRecyclerViewList(adapter: RecyclerView.Adapter<*>) = apply {
|
fun <B : AlertDialog.Builder> B.setRecyclerViewList(adapter: RecyclerView.Adapter<*>) = apply {
|
||||||
val recyclerView = RecyclerView(context)
|
val recyclerView = RecyclerView(context)
|
||||||
recyclerView.layoutManager = LinearLayoutManager(context)
|
recyclerView.layoutManager = LinearLayoutManager(context)
|
||||||
recyclerView.updatePadding(
|
recyclerView.updatePadding(
|
||||||
top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing),
|
top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing),
|
||||||
)
|
)
|
||||||
recyclerView.clipToPadding = false
|
recyclerView.clipToPadding = false
|
||||||
recyclerView.adapter = adapter
|
recyclerView.adapter = adapter
|
||||||
setView(recyclerView)
|
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.annotation.Px
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
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(
|
override fun getItemOffsets(
|
||||||
outRect: Rect,
|
outRect: Rect,
|
||||||
@@ -13,6 +16,6 @@ class SpacingItemDecoration(@Px private val spacing: Int) : RecyclerView.ItemDec
|
|||||||
parent: RecyclerView,
|
parent: RecyclerView,
|
||||||
state: RecyclerView.State,
|
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
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(),
|
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(),
|
||||||
OnApplyWindowInsetsListener,
|
OnApplyWindowInsetsListener {
|
||||||
ExceptionResolver.Host {
|
|
||||||
|
|
||||||
private var waitingForDismissAllowingStateLoss = false
|
private var waitingForDismissAllowingStateLoss = false
|
||||||
private var isFitToContentsDisabled = false
|
private var isFitToContentsDisabled = false
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
val data = it.tag
|
val data = it.tag
|
||||||
onChipCloseClickListener?.onChipCloseClick(chip, data) ?: onChipClickListener?.onChipClick(chip, data)
|
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 chipStyle: Int
|
||||||
private val iconsVisible: Boolean
|
private val iconsVisible: Boolean
|
||||||
var onChipClickListener: OnChipClickListener? = null
|
var onChipClickListener: OnChipClickListener? = null
|
||||||
@@ -66,6 +71,8 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
var onChipCloseClickListener: OnChipCloseClickListener? = null
|
var onChipCloseClickListener: OnChipCloseClickListener? = null
|
||||||
|
|
||||||
|
var onChipLongClickListener: OnChipLongClickListener? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0)
|
val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0)
|
||||||
chipStyle = ta.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip)
|
chipStyle = ta.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip)
|
||||||
@@ -145,6 +152,7 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
setOnCloseIconClickListener(chipOnCloseListener)
|
setOnCloseIconClickListener(chipOnCloseListener)
|
||||||
setEnsureMinTouchTargetSize(false)
|
setEnsureMinTouchTargetSize(false)
|
||||||
setOnClickListener(chipOnClickListener)
|
setOnClickListener(chipOnClickListener)
|
||||||
|
setOnLongClickListener(chipOnLongClickListener)
|
||||||
isElegantTextHeight = false
|
isElegantTextHeight = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,4 +284,9 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
|
|
||||||
fun onChipCloseClick(chip: Chip, data: Any?)
|
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.CloudFlareBlockedException
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
|
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.IncompatiblePluginException
|
||||||
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
||||||
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
|
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.+$")
|
private val FNFE_MESSAGE_REGEX = Regex("^(/[^\\s:]+)?.+?\\s([A-Z]{2,6})?\\s.+$")
|
||||||
|
|
||||||
fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessageOrNull(resources)
|
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) {
|
private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) {
|
||||||
is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message
|
is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message
|
||||||
is CaughtException -> cause.getDisplayMessageOrNull(resources)
|
is CaughtException -> cause.getDisplayMessageOrNull(resources)
|
||||||
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
|
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
|
||||||
is ScrobblerAuthRequiredException -> resources.getString(
|
is ScrobblerAuthRequiredException -> resources.getString(
|
||||||
R.string.scrobbler_auth_required,
|
R.string.scrobbler_auth_required,
|
||||||
resources.getString(scrobbler.titleResId),
|
resources.getString(scrobbler.titleResId),
|
||||||
)
|
)
|
||||||
|
|
||||||
is AuthRequiredException -> resources.getString(R.string.auth_required)
|
is AuthRequiredException -> resources.getString(R.string.auth_required)
|
||||||
is InteractiveActionRequiredException -> resources.getString(R.string.additional_action_required)
|
is InteractiveActionRequiredException -> resources.getString(R.string.additional_action_required)
|
||||||
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message)
|
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message)
|
||||||
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
|
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
|
||||||
is ActivityNotFoundException,
|
is ActivityNotFoundException,
|
||||||
is UnsupportedOperationException,
|
is UnsupportedOperationException,
|
||||||
-> resources.getString(R.string.operation_not_supported)
|
-> resources.getString(R.string.operation_not_supported)
|
||||||
|
|
||||||
is TooManyRequestExceptions -> {
|
is TooManyRequestExceptions -> {
|
||||||
val delay = getRetryDelay()
|
val delay = getRetryDelay()
|
||||||
val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) {
|
val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) {
|
||||||
resources.formatDurationShort(delay)
|
resources.formatDurationShort(delay)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
if (formattedTime != null) {
|
if (formattedTime != null) {
|
||||||
resources.getString(R.string.too_many_requests_message_retry, formattedTime)
|
resources.getString(R.string.too_many_requests_message_retry, formattedTime)
|
||||||
} else {
|
} else {
|
||||||
resources.getString(R.string.too_many_requests_message)
|
resources.getString(R.string.too_many_requests_message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is ZipException -> resources.getString(R.string.error_corrupted_zip, this.message.orEmpty())
|
is ZipException -> resources.getString(R.string.error_corrupted_zip, this.message.orEmpty())
|
||||||
is SQLiteFullException -> resources.getString(R.string.error_no_space_left)
|
is SQLiteFullException -> resources.getString(R.string.error_no_space_left)
|
||||||
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||||
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
||||||
is FileNotFoundException -> parseMessage(resources) ?: message
|
is FileNotFoundException -> parseMessage(resources) ?: message
|
||||||
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
||||||
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
|
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
|
||||||
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
||||||
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
|
is EmptyMangaException -> reason?.let { resources.getString(it.msgResId) } ?: cause?.getDisplayMessage(resources)
|
||||||
is SyncApiException,
|
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
|
||||||
is ContentUnavailableException -> message
|
is SyncApiException,
|
||||||
|
is ContentUnavailableException -> message
|
||||||
|
|
||||||
is ParseException -> shortMessage
|
is ParseException -> shortMessage
|
||||||
is ConnectException,
|
is ConnectException,
|
||||||
is UnknownHostException,
|
is UnknownHostException,
|
||||||
is NoRouteToHostException,
|
is NoRouteToHostException,
|
||||||
is SocketTimeoutException -> resources.getString(R.string.network_error)
|
is SocketTimeoutException -> resources.getString(R.string.network_error)
|
||||||
|
|
||||||
is ImageDecodeException -> {
|
is ImageDecodeException -> {
|
||||||
val type = format?.substringBefore('/')
|
val type = format?.substringBefore('/')
|
||||||
val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) }
|
val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) }
|
||||||
if (type.isNullOrEmpty() || type == "image") {
|
if (type.isNullOrEmpty() || type == "image") {
|
||||||
resources.getString(R.string.error_image_format, formatString)
|
resources.getString(R.string.error_image_format, formatString)
|
||||||
} else {
|
} else {
|
||||||
resources.getString(R.string.error_not_image, formatString)
|
resources.getString(R.string.error_not_image, formatString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
|
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
|
||||||
is IncompatiblePluginException -> {
|
is IncompatiblePluginException -> {
|
||||||
cause?.getDisplayMessageOrNull(resources)?.let {
|
cause?.getDisplayMessageOrNull(resources)?.let {
|
||||||
resources.getString(R.string.plugin_incompatible_with_cause, it)
|
resources.getString(R.string.plugin_incompatible_with_cause, it)
|
||||||
} ?: resources.getString(R.string.plugin_incompatible)
|
} ?: resources.getString(R.string.plugin_incompatible)
|
||||||
}
|
}
|
||||||
|
|
||||||
is WrongPasswordException -> resources.getString(R.string.wrong_password)
|
is WrongPasswordException -> resources.getString(R.string.wrong_password)
|
||||||
is NotFoundException -> resources.getString(R.string.not_found_404)
|
is NotFoundException -> resources.getString(R.string.not_found_404)
|
||||||
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
|
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
|
||||||
|
|
||||||
is HttpException -> getHttpDisplayMessage(response.code, resources)
|
is HttpException -> getHttpDisplayMessage(response.code, resources)
|
||||||
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
|
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
|
||||||
|
|
||||||
else -> mapDisplayMessage(message, resources) ?: message
|
else -> mapDisplayMessage(message, resources) ?: message
|
||||||
}.takeUnless { it.isNullOrBlank() }
|
}.takeUnless { it.isNullOrBlank() }
|
||||||
|
|
||||||
@DrawableRes
|
@DrawableRes
|
||||||
fun Throwable.getDisplayIcon(): Int = when (this) {
|
fun Throwable.getDisplayIcon(): Int = when (this) {
|
||||||
is AuthRequiredException -> R.drawable.ic_auth_key_large
|
is AuthRequiredException -> R.drawable.ic_auth_key_large
|
||||||
is CloudFlareProtectedException -> R.drawable.ic_bot_large
|
is CloudFlareProtectedException -> R.drawable.ic_bot_large
|
||||||
is UnknownHostException,
|
is UnknownHostException,
|
||||||
is SocketTimeoutException,
|
is SocketTimeoutException,
|
||||||
is ConnectException,
|
is ConnectException,
|
||||||
is NoRouteToHostException,
|
is NoRouteToHostException,
|
||||||
is ProtocolException -> R.drawable.ic_plug_large
|
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
|
is InteractiveActionRequiredException -> R.drawable.ic_interaction_large
|
||||||
else -> R.drawable.ic_error_large
|
else -> R.drawable.ic_error_large
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Throwable.getCauseUrl(): String? = when (this) {
|
fun Throwable.getCauseUrl(): String? = when (this) {
|
||||||
is ParseException -> url
|
is ParseException -> url
|
||||||
is NotFoundException -> url
|
is NotFoundException -> url
|
||||||
is TooManyRequestExceptions -> url
|
is TooManyRequestExceptions -> url
|
||||||
is CaughtException -> cause.getCauseUrl()
|
is CaughtException -> cause.getCauseUrl()
|
||||||
is WrapperIOException -> cause.getCauseUrl()
|
is WrapperIOException -> cause.getCauseUrl()
|
||||||
is NoDataReceivedException -> url
|
is NoDataReceivedException -> url
|
||||||
is CloudFlareBlockedException -> url
|
is CloudFlareBlockedException -> url
|
||||||
is CloudFlareProtectedException -> url
|
is CloudFlareProtectedException -> url
|
||||||
is InteractiveActionRequiredException -> url
|
is InteractiveActionRequiredException -> url
|
||||||
is HttpStatusException -> url
|
is HttpStatusException -> url
|
||||||
is HttpException -> (response.delegate as? Response)?.request?.url?.toString()
|
is UnsupportedSourceException -> manga?.publicUrl?.takeIf { it.isHttpUrl() }
|
||||||
else -> null
|
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) {
|
private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) {
|
||||||
HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404)
|
HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404)
|
||||||
HttpURLConnection.HTTP_FORBIDDEN -> resources.getString(R.string.access_denied_403)
|
HttpURLConnection.HTTP_FORBIDDEN -> resources.getString(R.string.access_denied_403)
|
||||||
HttpURLConnection.HTTP_GATEWAY_TIMEOUT -> resources.getString(R.string.network_unavailable)
|
HttpURLConnection.HTTP_GATEWAY_TIMEOUT -> resources.getString(R.string.network_unavailable)
|
||||||
in 500..599 -> resources.getString(R.string.server_error, statusCode)
|
in 500..599 -> resources.getString(R.string.server_error, statusCode)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mapDisplayMessage(msg: String?, resources: Resources): String? = when {
|
private fun mapDisplayMessage(msg: String?, resources: Resources): String? = when {
|
||||||
msg.isNullOrEmpty() -> null
|
msg.isNullOrEmpty() -> null
|
||||||
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
|
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
|
||||||
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
|
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 == 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_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 == 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 == 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_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)
|
msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Throwable.isReportable(): Boolean {
|
fun Throwable.isReportable(): Boolean {
|
||||||
if (this is Error) {
|
if (this is Error) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (this is CaughtException) {
|
if (this is CaughtException) {
|
||||||
return cause.isReportable()
|
return cause.isReportable()
|
||||||
}
|
}
|
||||||
if (this is WrapperIOException) {
|
if (this is WrapperIOException) {
|
||||||
return cause.isReportable()
|
return cause.isReportable()
|
||||||
}
|
}
|
||||||
if (ExceptionResolver.canResolve(this)) {
|
if (ExceptionResolver.canResolve(this)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (this is ParseException
|
if (this is ParseException
|
||||||
|| this.isNetworkError()
|
|| this.isNetworkError()
|
||||||
|| this is CloudFlareBlockedException
|
|| this is CloudFlareBlockedException
|
||||||
|| this is CloudFlareProtectedException
|
|| this is CloudFlareProtectedException
|
||||||
|| this is BadBackupFormatException
|
|| this is BadBackupFormatException
|
||||||
|| this is WrongPasswordException
|
|| this is WrongPasswordException
|
||||||
|| this is TooManyRequestExceptions
|
|| this is TooManyRequestExceptions
|
||||||
|| this is HttpStatusException
|
|| this is HttpStatusException
|
||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Throwable.isNetworkError(): Boolean {
|
fun Throwable.isNetworkError(): Boolean {
|
||||||
return this is UnknownHostException
|
return this is UnknownHostException
|
||||||
|| this is SocketTimeoutException
|
|| this is SocketTimeoutException
|
||||||
|| this is StreamResetException
|
|| this is StreamResetException
|
||||||
|| this is SocketException
|
|| this is SocketException
|
||||||
|| this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT
|
|| this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Throwable.report(silent: Boolean = false) {
|
fun Throwable.report(silent: Boolean = false) {
|
||||||
val exception = CaughtException(this)
|
val exception = CaughtException(this)
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
exception.sendWithAcra()
|
exception.sendWithAcra()
|
||||||
} else if (!BuildConfig.DEBUG) {
|
} else if (!BuildConfig.DEBUG) {
|
||||||
exception.sendSilentlyWithAcra()
|
exception.sendSilentlyWithAcra()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Throwable.isWebViewUnavailable(): Boolean {
|
fun Throwable.isWebViewUnavailable(): Boolean {
|
||||||
val trace = stackTraceToString()
|
val trace = stackTraceToString()
|
||||||
return trace.contains("android.webkit.WebView.<init>")
|
return trace.contains("android.webkit.WebView.<init>")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("FunctionName")
|
@Suppress("FunctionName")
|
||||||
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)
|
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)
|
||||||
|
|
||||||
fun FileNotFoundException.getFile(): File? {
|
fun FileNotFoundException.getFile(): File? {
|
||||||
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
||||||
return groups.getOrNull(1)?.let { File(it) }
|
return groups.getOrNull(1)?.let { File(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun FileNotFoundException.parseMessage(resources: Resources): String? {
|
fun FileNotFoundException.parseMessage(resources: Resources): String? {
|
||||||
/*
|
/*
|
||||||
Examples:
|
Examples:
|
||||||
/storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system)
|
/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/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)
|
/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 groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
||||||
val path = groups.getOrNull(1)
|
val path = groups.getOrNull(1)
|
||||||
val error = groups.getOrNull(2)
|
val error = groups.getOrNull(2)
|
||||||
val baseMessageIs = when (error) {
|
val baseMessageIs = when (error) {
|
||||||
"EROFS" -> R.string.no_write_permission_to_file
|
"EROFS" -> R.string.no_write_permission_to_file
|
||||||
"ENOENT" -> R.string.file_not_found
|
"ENOENT" -> R.string.file_not_found
|
||||||
else -> return null
|
else -> return null
|
||||||
}
|
}
|
||||||
return if (path.isNullOrEmpty()) {
|
return if (path.isNullOrEmpty()) {
|
||||||
resources.getString(baseMessageIs)
|
resources.getString(baseMessageIs)
|
||||||
} else {
|
} else {
|
||||||
resources.getString(
|
resources.getString(
|
||||||
R.string.inline_preference_pattern,
|
R.string.inline_preference_pattern,
|
||||||
resources.getString(baseMessageIs),
|
resources.getString(baseMessageIs),
|
||||||
path,
|
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.local.domain.model.LocalManga
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||||
import org.koitharu.kotatsu.reader.data.filterChapters
|
import org.koitharu.kotatsu.reader.data.filterChapters
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
data class MangaDetails(
|
data class MangaDetails(
|
||||||
private val manga: Manga,
|
private val manga: Manga,
|
||||||
private val localManga: LocalManga?,
|
private val localManga: LocalManga?,
|
||||||
private val override: MangaOverride?,
|
private val override: MangaOverride?,
|
||||||
val description: CharSequence?,
|
val description: CharSequence?,
|
||||||
val isLoaded: Boolean,
|
val isLoaded: Boolean,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
constructor(manga: Manga) : this(
|
constructor(manga: Manga) : this(
|
||||||
manga = manga,
|
manga = manga,
|
||||||
localManga = null,
|
localManga = null,
|
||||||
override = null,
|
override = null,
|
||||||
description = null,
|
description = null,
|
||||||
isLoaded = false,
|
isLoaded = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
val id: Long
|
val id: Long
|
||||||
get() = manga.id
|
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 {
|
val chapters: Map<String?, List<MangaChapter>> by lazy {
|
||||||
allChapters.groupBy { it.branch }
|
allChapters.groupBy { it.branch }
|
||||||
}
|
}
|
||||||
|
|
||||||
val isLocal
|
val isLocal
|
||||||
get() = manga.isLocal
|
get() = manga.isLocal
|
||||||
|
|
||||||
val local: LocalManga?
|
val local: LocalManga?
|
||||||
get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null
|
get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null
|
||||||
|
|
||||||
val coverUrl: String?
|
val coverUrl: String?
|
||||||
get() = override?.coverUrl
|
get() = override?.coverUrl
|
||||||
.ifNullOrEmpty { manga.largeCoverUrl }
|
.ifNullOrEmpty { manga.largeCoverUrl }
|
||||||
.ifNullOrEmpty { manga.coverUrl }
|
.ifNullOrEmpty { manga.coverUrl }
|
||||||
.ifNullOrEmpty { localManga?.manga?.coverUrl }
|
.ifNullOrEmpty { localManga?.manga?.coverUrl }
|
||||||
?.nullIfEmpty()
|
?.nullIfEmpty()
|
||||||
|
|
||||||
private val mergedManga by lazy {
|
val isRestricted: Boolean
|
||||||
if (localManga == null) {
|
get() = manga.state == MangaState.RESTRICTED
|
||||||
// fast path
|
|
||||||
manga.withOverride(override)
|
|
||||||
} else {
|
|
||||||
manga.copy(
|
|
||||||
title = override?.title.ifNullOrEmpty { manga.title },
|
|
||||||
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
|
|
||||||
largeCoverUrl = override?.coverUrl.ifNullOrEmpty { manga.largeCoverUrl },
|
|
||||||
contentRating = override?.contentRating ?: manga.contentRating,
|
|
||||||
chapters = allChapters,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toManga() = mergedManga
|
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? {
|
fun toManga() = mergedManga
|
||||||
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
|
|
||||||
return it
|
|
||||||
}
|
|
||||||
return manga.source.getLocale()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun filterChapters(branch: String?) = copy(
|
fun getLocale(): Locale? {
|
||||||
manga = manga.filterChapters(branch),
|
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
|
||||||
localManga = localManga?.run {
|
return it
|
||||||
copy(manga = manga.filterChapters(branch))
|
}
|
||||||
},
|
return manga.source.getLocale()
|
||||||
)
|
}
|
||||||
|
|
||||||
private fun mergeChapters(): List<MangaChapter> {
|
fun filterChapters(branch: String?) = copy(
|
||||||
val chapters = manga.chapters
|
manga = manga.filterChapters(branch),
|
||||||
val localChapters = local?.manga?.chapters.orEmpty()
|
localManga = localManga?.run {
|
||||||
if (chapters.isNullOrEmpty()) {
|
copy(manga = manga.filterChapters(branch))
|
||||||
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? {
|
private fun mergeChapters(): List<MangaChapter> {
|
||||||
if (name.isNullOrEmpty()) {
|
val chapters = manga.chapters
|
||||||
return null
|
val localChapters = local?.manga?.chapters.orEmpty()
|
||||||
}
|
if (chapters.isNullOrEmpty()) {
|
||||||
return Locale.getAvailableLocales().find { lc ->
|
return localChapters
|
||||||
name.contains(lc.getDisplayName(lc), ignoreCase = true) ||
|
}
|
||||||
name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) ||
|
val localMap = if (localChapters.isNotEmpty()) {
|
||||||
name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) ||
|
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
|
||||||
name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true)
|
} 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
|
package org.koitharu.kotatsu.details.domain
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||||
import org.koitharu.kotatsu.core.model.isNsfw
|
import org.koitharu.kotatsu.core.model.isNsfw
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
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 chapter = details.findChapterById(history.chapterId) ?: return PROGRESS_NONE
|
||||||
val chapters = details.getChapters(chapter.branch)
|
val chapters = details.getChapters(chapter.branch)
|
||||||
|
val chapterRepo = if (repo.source == chapter.source) {
|
||||||
|
repo
|
||||||
|
} else {
|
||||||
|
mangaRepositoryFactory.create(chapter.source)
|
||||||
|
}
|
||||||
val chaptersCount = chapters.size
|
val chaptersCount = chapters.size
|
||||||
if (chaptersCount == 0) {
|
if (chaptersCount == 0) {
|
||||||
return PROGRESS_NONE
|
return PROGRESS_NONE
|
||||||
}
|
}
|
||||||
val chapterIndex = chapters.indexOfFirst { x -> x.id == history.chapterId }
|
val chapterIndex = chapters.indexOfFirst { x -> x.id == history.chapterId }
|
||||||
val pagesCount = repo.getPages(chapter).size
|
val pagesCount = chapterRepo.getPages(chapter).size
|
||||||
if (pagesCount == 0) {
|
if (pagesCount == 0) {
|
||||||
return PROGRESS_NONE
|
return PROGRESS_NONE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class ReadingTimeUseCase @Inject constructor(
|
|||||||
// Impossible task, I guess. Good luck on this.
|
// Impossible task, I guess. Good luck on this.
|
||||||
var averageTimeSec: Int = 20 /* pages */ * getSecondsPerPage(manga.id) * chapters.size
|
var averageTimeSec: Int = 20 /* pages */ * getSecondsPerPage(manga.id) * chapters.size
|
||||||
if (isOnHistoryBranch) {
|
if (isOnHistoryBranch) {
|
||||||
averageTimeSec = (averageTimeSec * (1f - checkNotNull(history).percent)).roundToInt()
|
averageTimeSec = (averageTimeSec * (1f - history.percent)).roundToInt()
|
||||||
}
|
}
|
||||||
if (averageTimeSec < 60) {
|
if (averageTimeSec < 60) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.details.ui
|
|||||||
|
|
||||||
import android.app.assist.AssistContent
|
import android.app.assist.AssistContent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.SpannedString
|
import android.text.SpannedString
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ class DetailsViewModel @Inject constructor(
|
|||||||
get() = scrobblers.any { it.isEnabled }
|
get() = scrobblers.any { it.isEnabled }
|
||||||
|
|
||||||
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
|
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
|
||||||
|
.withErrorHandling()
|
||||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
val relatedManga: StateFlow<List<MangaListModel>> = manga.mapLatest {
|
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 onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets
|
||||||
|
|
||||||
override fun onStateChanged(sheet: View, newState: Int) {
|
override fun onStateChanged(sheet: View, newState: Int) {
|
||||||
if (newState == STATE_DRAGGING || newState == STATE_SETTLING) {
|
val binding = viewBinding ?: return
|
||||||
return
|
binding.layoutTouchBlock.isTouchEventsAllowed = dialog != null || newState != STATE_COLLAPSED
|
||||||
}
|
if (newState == STATE_DRAGGING || newState == STATE_SETTLING) {
|
||||||
val binding = viewBinding ?: return
|
return
|
||||||
|
}
|
||||||
val isActionModeStarted = actionModeDelegate?.isActionModeStarted == true
|
val isActionModeStarted = actionModeDelegate?.isActionModeStarted == true
|
||||||
binding.toolbar.menuView?.isVisible = newState == STATE_EXPANDED && !isActionModeStarted
|
binding.toolbar.menuView?.isVisible = newState == STATE_EXPANDED && !isActionModeStarted
|
||||||
binding.splitButtonRead.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.Menu
|
||||||
import android.view.MenuInflater
|
import android.view.MenuInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.appcompat.view.ActionMode
|
import androidx.appcompat.view.ActionMode
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.snackbar.Snackbar
|
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.nav.AppRouter
|
||||||
import org.koitharu.kotatsu.core.ui.list.BaseListSelectionCallback
|
import org.koitharu.kotatsu.core.ui.list.BaseListSelectionCallback
|
||||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
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.toCollection
|
||||||
import org.koitharu.kotatsu.core.util.ext.toSet
|
import org.koitharu.kotatsu.core.util.ext.toSet
|
||||||
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
|
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
|
||||||
@@ -78,11 +80,20 @@ class ChaptersSelectionCallback(
|
|||||||
ids.size == manga.chapters?.size -> viewModel.deleteLocal()
|
ids.size == manga.chapters?.size -> viewModel.deleteLocal()
|
||||||
else -> {
|
else -> {
|
||||||
LocalChaptersRemoveService.start(recyclerView.context, manga, ids.toSet())
|
LocalChaptersRemoveService.start(recyclerView.context, manga, ids.toSet())
|
||||||
Snackbar.make(
|
try {
|
||||||
recyclerView,
|
Snackbar.make(
|
||||||
R.string.chapters_will_removed_background,
|
recyclerView,
|
||||||
Snackbar.LENGTH_LONG,
|
R.string.chapters_will_removed_background,
|
||||||
).show()
|
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()
|
mode?.finish()
|
||||||
|
|||||||
@@ -105,7 +105,14 @@ class PagesViewModel @Inject constructor(
|
|||||||
chaptersLoader.peekChapter(it) != null
|
chaptersLoader.peekChapter(it) != null
|
||||||
} ?: state.details.allChapters.firstOrNull()?.id ?: return
|
} ?: state.details.allChapters.firstOrNull()?.id ?: return
|
||||||
if (!chaptersLoader.hasPages(initialChapterId)) {
|
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)
|
updateList(state.readerState)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ class MangaSourcesRepository @Inject constructor(
|
|||||||
get() = db.getSourcesDao()
|
get() = db.getSourcesDao()
|
||||||
|
|
||||||
val allMangaSources: Set<MangaParserSource> = Collections.unmodifiableSet(
|
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> {
|
suspend fun getEnabledSources(): List<MangaSource> {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.explore.ui.adapter
|
package org.koitharu.kotatsu.explore.ui.adapter
|
||||||
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.appcompat.widget.TooltipCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.text.bold
|
import androidx.core.text.bold
|
||||||
import androidx.core.text.buildSpannedString
|
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.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.plus
|
import kotlinx.coroutines.plus
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
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.lifecycleScope
|
||||||
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
|
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
|
||||||
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
|
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.model.FilterProperty
|
||||||
import org.koitharu.kotatsu.filter.ui.tags.TagTitleComparator
|
import org.koitharu.kotatsu.filter.ui.tags.TagTitleComparator
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||||
@@ -48,469 +51,502 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
@ViewModelScoped
|
@ViewModelScoped
|
||||||
class FilterCoordinator @Inject constructor(
|
class FilterCoordinator @Inject constructor(
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
mangaRepositoryFactory: MangaRepository.Factory,
|
mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
private val searchRepository: MangaSearchRepository,
|
private val searchRepository: MangaSearchRepository,
|
||||||
lifecycle: ViewModelLifecycle,
|
private val savedFiltersRepository: SavedFiltersRepository,
|
||||||
|
lifecycle: ViewModelLifecycle,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val coroutineScope = lifecycle.lifecycleScope + Dispatchers.Default
|
private val coroutineScope = lifecycle.lifecycleScope + Dispatchers.Default
|
||||||
private val repository = mangaRepositoryFactory.create(MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE]))
|
private val repository = mangaRepositoryFactory.create(MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE]))
|
||||||
private val sourceLocale = (repository.source as? MangaParserSource)?.locale
|
private val sourceLocale = (repository.source as? MangaParserSource)?.locale
|
||||||
|
|
||||||
private val currentListFilter = MutableStateFlow(MangaListFilter.EMPTY)
|
private val currentListFilter = MutableStateFlow(MangaListFilter.EMPTY)
|
||||||
private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder)
|
private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder)
|
||||||
|
|
||||||
private val availableSortOrders = repository.sortOrders
|
private val availableSortOrders = repository.sortOrders
|
||||||
private val filterOptions = suspendLazy { repository.getFilterOptions() }
|
private val filterOptions = suspendLazy { repository.getFilterOptions() }
|
||||||
val capabilities = repository.filterCapabilities
|
|
||||||
|
|
||||||
val mangaSource: MangaSource
|
val capabilities = repository.filterCapabilities
|
||||||
get() = repository.source
|
|
||||||
|
|
||||||
val isFilterApplied: Boolean
|
val mangaSource: MangaSource
|
||||||
get() = currentListFilter.value.isNotEmpty()
|
get() = repository.source
|
||||||
|
|
||||||
val query: StateFlow<String?> = currentListFilter.map { it.query }
|
val isFilterApplied: Boolean
|
||||||
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
|
get() = currentListFilter.value.isNotEmpty()
|
||||||
|
|
||||||
val sortOrder: StateFlow<FilterProperty<SortOrder>> = currentSortOrder.map { selected ->
|
val query: StateFlow<String?> = currentListFilter.map { it.query }
|
||||||
FilterProperty(
|
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
|
||||||
availableItems = availableSortOrders.sortedByOrdinal(),
|
|
||||||
selectedItem = selected,
|
|
||||||
)
|
|
||||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
|
||||||
|
|
||||||
val tags: StateFlow<FilterProperty<MangaTag>> = combine(
|
val sortOrder: StateFlow<FilterProperty<SortOrder>> = currentSortOrder.map { selected ->
|
||||||
getTopTags(TAGS_LIMIT),
|
FilterProperty(
|
||||||
currentListFilter.distinctUntilChangedBy { it.tags },
|
availableItems = availableSortOrders.sortedByOrdinal(),
|
||||||
) { available, selected ->
|
selectedItem = selected,
|
||||||
available.fold(
|
)
|
||||||
onSuccess = {
|
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||||
FilterProperty(
|
|
||||||
availableItems = it.addFirstDistinct(selected.tags),
|
|
||||||
selectedItems = selected.tags,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onFailure = {
|
|
||||||
FilterProperty.error(it)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
|
||||||
|
|
||||||
val tagsExcluded: StateFlow<FilterProperty<MangaTag>> = if (capabilities.isTagsExclusionSupported) {
|
val tags: StateFlow<FilterProperty<MangaTag>> = combine(
|
||||||
combine(
|
getTopTags(TAGS_LIMIT),
|
||||||
getBottomTags(TAGS_LIMIT),
|
currentListFilter.distinctUntilChangedBy { it.tags },
|
||||||
currentListFilter.distinctUntilChangedBy { it.tagsExclude },
|
) { available, selected ->
|
||||||
) { available, selected ->
|
available.fold(
|
||||||
available.fold(
|
onSuccess = {
|
||||||
onSuccess = {
|
FilterProperty(
|
||||||
FilterProperty(
|
availableItems = it.addFirstDistinct(selected.tags),
|
||||||
availableItems = it.addFirstDistinct(selected.tagsExclude),
|
selectedItems = selected.tags,
|
||||||
selectedItems = selected.tagsExclude,
|
)
|
||||||
)
|
},
|
||||||
},
|
onFailure = {
|
||||||
onFailure = {
|
FilterProperty.error(it)
|
||||||
FilterProperty.error(it)
|
},
|
||||||
},
|
)
|
||||||
)
|
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
|
||||||
} else {
|
|
||||||
MutableStateFlow(FilterProperty.EMPTY)
|
|
||||||
}
|
|
||||||
|
|
||||||
val states: StateFlow<FilterProperty<MangaState>> = combine(
|
val tagsExcluded: StateFlow<FilterProperty<MangaTag>> = if (capabilities.isTagsExclusionSupported) {
|
||||||
filterOptions.asFlow(),
|
combine(
|
||||||
currentListFilter.distinctUntilChangedBy { it.states },
|
getBottomTags(TAGS_LIMIT),
|
||||||
) { available, selected ->
|
currentListFilter.distinctUntilChangedBy { it.tagsExclude },
|
||||||
available.fold(
|
) { available, selected ->
|
||||||
onSuccess = {
|
available.fold(
|
||||||
FilterProperty(
|
onSuccess = {
|
||||||
availableItems = it.availableStates.sortedByOrdinal(),
|
FilterProperty(
|
||||||
selectedItems = selected.states,
|
availableItems = it.addFirstDistinct(selected.tagsExclude),
|
||||||
)
|
selectedItems = selected.tagsExclude,
|
||||||
},
|
)
|
||||||
onFailure = {
|
},
|
||||||
FilterProperty.error(it)
|
onFailure = {
|
||||||
},
|
FilterProperty.error(it)
|
||||||
)
|
},
|
||||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
)
|
||||||
|
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||||
|
} else {
|
||||||
|
MutableStateFlow(FilterProperty.EMPTY)
|
||||||
|
}
|
||||||
|
|
||||||
val contentRating: StateFlow<FilterProperty<ContentRating>> = combine(
|
val authors: StateFlow<FilterProperty<String>> = if (capabilities.isAuthorSearchSupported) {
|
||||||
filterOptions.asFlow(),
|
combine(
|
||||||
currentListFilter.distinctUntilChangedBy { it.contentRating },
|
flow { emit(searchRepository.getAuthors(repository.source, TAGS_LIMIT)) },
|
||||||
) { available, selected ->
|
currentListFilter.distinctUntilChangedBy { it.author },
|
||||||
available.fold(
|
) { available, selected ->
|
||||||
onSuccess = {
|
FilterProperty(
|
||||||
FilterProperty(
|
availableItems = available,
|
||||||
availableItems = it.availableContentRating.sortedByOrdinal(),
|
selectedItems = setOfNotNull(selected.author),
|
||||||
selectedItems = selected.contentRating,
|
)
|
||||||
)
|
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||||
},
|
} else {
|
||||||
onFailure = {
|
MutableStateFlow(FilterProperty.EMPTY)
|
||||||
FilterProperty.error(it)
|
}
|
||||||
},
|
|
||||||
)
|
|
||||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
|
||||||
|
|
||||||
val contentTypes: StateFlow<FilterProperty<ContentType>> = combine(
|
val states: StateFlow<FilterProperty<MangaState>> = combine(
|
||||||
filterOptions.asFlow(),
|
filterOptions.asFlow(),
|
||||||
currentListFilter.distinctUntilChangedBy { it.types },
|
currentListFilter.distinctUntilChangedBy { it.states },
|
||||||
) { available, selected ->
|
) { available, selected ->
|
||||||
available.fold(
|
available.fold(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
FilterProperty(
|
FilterProperty(
|
||||||
availableItems = it.availableContentTypes.sortedByOrdinal(),
|
availableItems = it.availableStates.sortedByOrdinal(),
|
||||||
selectedItems = selected.types,
|
selectedItems = selected.states,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onFailure = {
|
onFailure = {
|
||||||
FilterProperty.error(it)
|
FilterProperty.error(it)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||||
|
|
||||||
val demographics: StateFlow<FilterProperty<Demographic>> = combine(
|
val contentRating: StateFlow<FilterProperty<ContentRating>> = combine(
|
||||||
filterOptions.asFlow(),
|
filterOptions.asFlow(),
|
||||||
currentListFilter.distinctUntilChangedBy { it.demographics },
|
currentListFilter.distinctUntilChangedBy { it.contentRating },
|
||||||
) { available, selected ->
|
) { available, selected ->
|
||||||
available.fold(
|
available.fold(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
FilterProperty(
|
FilterProperty(
|
||||||
availableItems = it.availableDemographics.sortedByOrdinal(),
|
availableItems = it.availableContentRating.sortedByOrdinal(),
|
||||||
selectedItems = selected.demographics,
|
selectedItems = selected.contentRating,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onFailure = {
|
onFailure = {
|
||||||
FilterProperty.error(it)
|
FilterProperty.error(it)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||||
|
|
||||||
val locale: StateFlow<FilterProperty<Locale?>> = combine(
|
val contentTypes: StateFlow<FilterProperty<ContentType>> = combine(
|
||||||
filterOptions.asFlow(),
|
filterOptions.asFlow(),
|
||||||
currentListFilter.distinctUntilChangedBy { it.locale },
|
currentListFilter.distinctUntilChangedBy { it.types },
|
||||||
) { available, selected ->
|
) { available, selected ->
|
||||||
available.fold(
|
available.fold(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
FilterProperty(
|
FilterProperty(
|
||||||
availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null),
|
availableItems = it.availableContentTypes.sortedByOrdinal(),
|
||||||
selectedItems = setOfNotNull(selected.locale),
|
selectedItems = selected.types,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onFailure = {
|
onFailure = {
|
||||||
FilterProperty.error(it)
|
FilterProperty.error(it)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||||
|
|
||||||
val originalLocale: StateFlow<FilterProperty<Locale?>> = if (capabilities.isOriginalLocaleSupported) {
|
val demographics: StateFlow<FilterProperty<Demographic>> = combine(
|
||||||
combine(
|
filterOptions.asFlow(),
|
||||||
filterOptions.asFlow(),
|
currentListFilter.distinctUntilChangedBy { it.demographics },
|
||||||
currentListFilter.distinctUntilChangedBy { it.originalLocale },
|
) { available, selected ->
|
||||||
) { available, selected ->
|
available.fold(
|
||||||
available.fold(
|
onSuccess = {
|
||||||
onSuccess = {
|
FilterProperty(
|
||||||
FilterProperty(
|
availableItems = it.availableDemographics.sortedByOrdinal(),
|
||||||
availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null),
|
selectedItems = selected.demographics,
|
||||||
selectedItems = setOfNotNull(selected.originalLocale),
|
)
|
||||||
)
|
},
|
||||||
},
|
onFailure = {
|
||||||
onFailure = {
|
FilterProperty.error(it)
|
||||||
FilterProperty.error(it)
|
},
|
||||||
},
|
)
|
||||||
)
|
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
|
||||||
} else {
|
|
||||||
MutableStateFlow(FilterProperty.EMPTY)
|
|
||||||
}
|
|
||||||
|
|
||||||
val year: StateFlow<FilterProperty<Int>> = if (capabilities.isYearSupported) {
|
val locale: StateFlow<FilterProperty<Locale?>> = combine(
|
||||||
currentListFilter.distinctUntilChangedBy { it.year }.map { selected ->
|
filterOptions.asFlow(),
|
||||||
FilterProperty(
|
currentListFilter.distinctUntilChangedBy { it.locale },
|
||||||
availableItems = listOf(YEAR_MIN, MAX_YEAR),
|
) { available, selected ->
|
||||||
selectedItems = setOf(selected.year),
|
available.fold(
|
||||||
)
|
onSuccess = {
|
||||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
FilterProperty(
|
||||||
} else {
|
availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null),
|
||||||
MutableStateFlow(FilterProperty.EMPTY)
|
selectedItems = setOfNotNull(selected.locale),
|
||||||
}
|
)
|
||||||
|
},
|
||||||
|
onFailure = {
|
||||||
|
FilterProperty.error(it)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||||
|
|
||||||
val yearRange: StateFlow<FilterProperty<Int>> = if (capabilities.isYearRangeSupported) {
|
val originalLocale: StateFlow<FilterProperty<Locale?>> = if (capabilities.isOriginalLocaleSupported) {
|
||||||
currentListFilter.distinctUntilChanged { old, new ->
|
combine(
|
||||||
old.yearTo == new.yearTo && old.yearFrom == new.yearFrom
|
filterOptions.asFlow(),
|
||||||
}.map { selected ->
|
currentListFilter.distinctUntilChangedBy { it.originalLocale },
|
||||||
FilterProperty(
|
) { available, selected ->
|
||||||
availableItems = listOf(YEAR_MIN, MAX_YEAR),
|
available.fold(
|
||||||
selectedItems = setOf(selected.yearFrom.ifZero { YEAR_MIN }, selected.yearTo.ifZero { MAX_YEAR }),
|
onSuccess = {
|
||||||
)
|
FilterProperty(
|
||||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null),
|
||||||
} else {
|
selectedItems = setOfNotNull(selected.originalLocale),
|
||||||
MutableStateFlow(FilterProperty.EMPTY)
|
)
|
||||||
}
|
},
|
||||||
|
onFailure = {
|
||||||
|
FilterProperty.error(it)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||||
|
} else {
|
||||||
|
MutableStateFlow(FilterProperty.EMPTY)
|
||||||
|
}
|
||||||
|
|
||||||
fun reset() {
|
val year: StateFlow<FilterProperty<Int>> = if (capabilities.isYearSupported) {
|
||||||
currentListFilter.value = MangaListFilter.EMPTY
|
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(
|
val yearRange: StateFlow<FilterProperty<Int>> = if (capabilities.isYearRangeSupported) {
|
||||||
sortOrder = currentSortOrder.value,
|
currentListFilter.distinctUntilChanged { old, new ->
|
||||||
listFilter = currentListFilter.value,
|
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) {
|
fun reset() {
|
||||||
currentSortOrder.value = newSortOrder
|
currentListFilter.value = MangaListFilter.EMPTY
|
||||||
repository.defaultSortOrder = newSortOrder
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun set(value: MangaListFilter) {
|
fun snapshot() = Snapshot(
|
||||||
currentListFilter.value = value
|
sortOrder = currentSortOrder.value,
|
||||||
}
|
listFilter = currentListFilter.value,
|
||||||
|
)
|
||||||
|
|
||||||
fun setAdjusted(value: MangaListFilter) {
|
fun observe(): Flow<Snapshot> = combine(currentSortOrder, currentListFilter, ::Snapshot)
|
||||||
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 setQuery(value: String?) {
|
fun setSortOrder(newSortOrder: SortOrder) {
|
||||||
val newQuery = value?.trim()?.nullIfEmpty()
|
currentSortOrder.value = newSortOrder
|
||||||
currentListFilter.update { oldValue ->
|
repository.defaultSortOrder = newSortOrder
|
||||||
if (capabilities.isSearchWithFiltersSupported || newQuery == null) {
|
}
|
||||||
oldValue.copy(query = newQuery)
|
|
||||||
} else {
|
|
||||||
MangaListFilter(query = newQuery)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setLocale(value: Locale?) {
|
fun set(value: MangaListFilter) {
|
||||||
currentListFilter.update { oldValue ->
|
currentListFilter.value = value
|
||||||
oldValue.copy(
|
}
|
||||||
locale = value,
|
|
||||||
query = oldValue.takeQueryIfSupported(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setAuthor(value: String?) {
|
fun setAdjusted(value: MangaListFilter) {
|
||||||
currentListFilter.update { oldValue ->
|
var newFilter = value
|
||||||
oldValue.copy(
|
if (!newFilter.author.isNullOrEmpty() && !capabilities.isAuthorSearchSupported) {
|
||||||
author = value,
|
newFilter = newFilter.copy(
|
||||||
query = oldValue.takeQueryIfSupported(),
|
query = newFilter.author,
|
||||||
)
|
author = null,
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
if (!newFilter.query.isNullOrEmpty() && !newFilter.hasNonSearchOptions() && !capabilities.isSearchWithFiltersSupported) {
|
||||||
|
newFilter = MangaListFilter(query = newFilter.query)
|
||||||
|
}
|
||||||
|
set(newFilter)
|
||||||
|
}
|
||||||
|
|
||||||
fun setOriginalLocale(value: Locale?) {
|
fun saveCurrentFilter(name: String) = coroutineScope.launch {
|
||||||
currentListFilter.update { oldValue ->
|
savedFiltersRepository.save(repository.source, name, currentListFilter.value)
|
||||||
oldValue.copy(
|
}
|
||||||
originalLocale = value,
|
|
||||||
query = oldValue.takeQueryIfSupported(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setYear(value: Int) {
|
fun renameSavedFilter(id: Int, newName: String) = coroutineScope.launch {
|
||||||
currentListFilter.update { oldValue ->
|
savedFiltersRepository.rename(repository.source, id, newName)
|
||||||
oldValue.copy(
|
}
|
||||||
year = value,
|
|
||||||
query = oldValue.takeQueryIfSupported(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setYearRange(valueFrom: Int, valueTo: Int) {
|
fun deleteSavedFilter(id: Int) = coroutineScope.launch {
|
||||||
currentListFilter.update { oldValue ->
|
savedFiltersRepository.delete(repository.source, id)
|
||||||
oldValue.copy(
|
}
|
||||||
yearFrom = valueFrom,
|
|
||||||
yearTo = valueTo,
|
|
||||||
query = oldValue.takeQueryIfSupported(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleState(value: MangaState, isSelected: Boolean) {
|
fun setQuery(value: String?) {
|
||||||
currentListFilter.update { oldValue ->
|
val newQuery = value?.trim()?.nullIfEmpty()
|
||||||
oldValue.copy(
|
currentListFilter.update { oldValue ->
|
||||||
states = if (isSelected) oldValue.states + value else oldValue.states - value,
|
if (capabilities.isSearchWithFiltersSupported || newQuery == null) {
|
||||||
query = oldValue.takeQueryIfSupported(),
|
oldValue.copy(query = newQuery)
|
||||||
)
|
} else {
|
||||||
}
|
MangaListFilter(query = newQuery)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun toggleContentRating(value: ContentRating, isSelected: Boolean) {
|
fun setLocale(value: Locale?) {
|
||||||
currentListFilter.update { oldValue ->
|
currentListFilter.update { oldValue ->
|
||||||
oldValue.copy(
|
oldValue.copy(
|
||||||
contentRating = if (isSelected) oldValue.contentRating + value else oldValue.contentRating - value,
|
locale = value,
|
||||||
query = oldValue.takeQueryIfSupported(),
|
query = oldValue.takeQueryIfSupported(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleDemographic(value: Demographic, isSelected: Boolean) {
|
fun setAuthor(value: String?) {
|
||||||
currentListFilter.update { oldValue ->
|
currentListFilter.update { oldValue ->
|
||||||
oldValue.copy(
|
oldValue.copy(
|
||||||
demographics = if (isSelected) oldValue.demographics + value else oldValue.demographics - value,
|
author = value,
|
||||||
query = oldValue.takeQueryIfSupported(),
|
query = oldValue.takeQueryIfSupported(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleContentType(value: ContentType, isSelected: Boolean) {
|
fun setOriginalLocale(value: Locale?) {
|
||||||
currentListFilter.update { oldValue ->
|
currentListFilter.update { oldValue ->
|
||||||
oldValue.copy(
|
oldValue.copy(
|
||||||
types = if (isSelected) oldValue.types + value else oldValue.types - value,
|
originalLocale = value,
|
||||||
query = oldValue.takeQueryIfSupported(),
|
query = oldValue.takeQueryIfSupported(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleTag(value: MangaTag, isSelected: Boolean) {
|
fun setYear(value: Int) {
|
||||||
currentListFilter.update { oldValue ->
|
currentListFilter.update { oldValue ->
|
||||||
val newTags = if (capabilities.isMultipleTagsSupported) {
|
oldValue.copy(
|
||||||
if (isSelected) oldValue.tags + value else oldValue.tags - value
|
year = value,
|
||||||
} else {
|
query = oldValue.takeQueryIfSupported(),
|
||||||
if (isSelected) setOf(value) else emptySet()
|
)
|
||||||
}
|
}
|
||||||
oldValue.copy(
|
}
|
||||||
tags = newTags,
|
|
||||||
tagsExclude = oldValue.tagsExclude - newTags,
|
|
||||||
query = oldValue.takeQueryIfSupported(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleTagExclude(value: MangaTag, isSelected: Boolean) {
|
fun setYearRange(valueFrom: Int, valueTo: Int) {
|
||||||
currentListFilter.update { oldValue ->
|
currentListFilter.update { oldValue ->
|
||||||
val newTagsExclude = if (capabilities.isMultipleTagsSupported) {
|
oldValue.copy(
|
||||||
if (isSelected) oldValue.tagsExclude + value else oldValue.tagsExclude - value
|
yearFrom = valueFrom,
|
||||||
} else {
|
yearTo = valueTo,
|
||||||
if (isSelected) setOf(value) else emptySet()
|
query = oldValue.takeQueryIfSupported(),
|
||||||
}
|
)
|
||||||
oldValue.copy(
|
}
|
||||||
tags = oldValue.tags - newTagsExclude,
|
}
|
||||||
tagsExclude = newTagsExclude,
|
|
||||||
query = oldValue.takeQueryIfSupported(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAllTags(): Flow<Result<List<MangaTag>>> = filterOptions.asFlow().map {
|
fun toggleState(value: MangaState, isSelected: Boolean) {
|
||||||
it.map { x -> x.availableTags.sortedWithSafe(TagTitleComparator(sourceLocale)) }
|
currentListFilter.update { oldValue ->
|
||||||
}
|
oldValue.copy(
|
||||||
|
states = if (isSelected) oldValue.states + value else oldValue.states - value,
|
||||||
|
query = oldValue.takeQueryIfSupported(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun MangaListFilter.takeQueryIfSupported() = when {
|
fun toggleContentRating(value: ContentRating, isSelected: Boolean) {
|
||||||
capabilities.isSearchWithFiltersSupported -> query
|
currentListFilter.update { oldValue ->
|
||||||
query.isNullOrEmpty() -> query
|
oldValue.copy(
|
||||||
hasNonSearchOptions() -> null
|
contentRating = if (isSelected) oldValue.contentRating + value else oldValue.contentRating - value,
|
||||||
else -> query
|
query = oldValue.takeQueryIfSupported(),
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun getTopTags(limit: Int): Flow<Result<List<MangaTag>>> = combine(
|
fun toggleDemographic(value: Demographic, isSelected: Boolean) {
|
||||||
flow { emit(searchRepository.getTopTags(repository.source, limit)) },
|
currentListFilter.update { oldValue ->
|
||||||
filterOptions.asFlow(),
|
oldValue.copy(
|
||||||
) { suggested, options ->
|
demographics = if (isSelected) oldValue.demographics + value else oldValue.demographics - value,
|
||||||
val all = options.getOrNull()?.availableTags.orEmpty()
|
query = oldValue.takeQueryIfSupported(),
|
||||||
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 fun getBottomTags(limit: Int): Flow<Result<List<MangaTag>>> = combine(
|
fun toggleContentType(value: ContentType, isSelected: Boolean) {
|
||||||
flow { emit(searchRepository.getRareTags(repository.source, limit)) },
|
currentListFilter.update { oldValue ->
|
||||||
filterOptions.asFlow(),
|
oldValue.copy(
|
||||||
) { suggested, options ->
|
types = if (isSelected) oldValue.types + value else oldValue.types - value,
|
||||||
val all = options.getOrNull()?.availableTags.orEmpty()
|
query = oldValue.takeQueryIfSupported(),
|
||||||
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 fun <T> List<T>.addFirstDistinct(other: Collection<T>): List<T> {
|
fun toggleTag(value: MangaTag, isSelected: Boolean) {
|
||||||
val result = ArrayDeque<T>(this.size + other.size)
|
currentListFilter.update { oldValue ->
|
||||||
result.addAll(this)
|
val newTags = if (capabilities.isMultipleTagsSupported) {
|
||||||
for (item in other) {
|
if (isSelected) oldValue.tags + value else oldValue.tags - value
|
||||||
if (item !in result) {
|
} else {
|
||||||
result.addFirst(item)
|
if (isSelected) setOf(value) else emptySet()
|
||||||
}
|
}
|
||||||
}
|
oldValue.copy(
|
||||||
return result
|
tags = newTags,
|
||||||
}
|
tagsExclude = oldValue.tagsExclude - newTags,
|
||||||
|
query = oldValue.takeQueryIfSupported(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun <T> List<T>.addFirstDistinct(item: T): List<T> {
|
fun toggleTagExclude(value: MangaTag, isSelected: Boolean) {
|
||||||
val result = ArrayDeque<T>(this.size + 1)
|
currentListFilter.update { oldValue ->
|
||||||
result.addAll(this)
|
val newTagsExclude = if (capabilities.isMultipleTagsSupported) {
|
||||||
if (item !in result) {
|
if (isSelected) oldValue.tagsExclude + value else oldValue.tagsExclude - value
|
||||||
result.addFirst(item)
|
} else {
|
||||||
}
|
if (isSelected) setOf(value) else emptySet()
|
||||||
return result
|
}
|
||||||
}
|
oldValue.copy(
|
||||||
|
tags = oldValue.tags - newTagsExclude,
|
||||||
|
tagsExclude = newTagsExclude,
|
||||||
|
query = oldValue.takeQueryIfSupported(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class Snapshot(
|
fun getAllTags(): Flow<Result<List<MangaTag>>> = filterOptions.asFlow().map {
|
||||||
val sortOrder: SortOrder,
|
it.map { x -> x.availableTags.sortedWithSafe(TagTitleComparator(sourceLocale)) }
|
||||||
val listFilter: MangaListFilter,
|
}
|
||||||
)
|
|
||||||
|
|
||||||
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 fun <T> List<T>.addFirstDistinct(other: Collection<T>): List<T> {
|
||||||
private val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1
|
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? {
|
private fun <T> List<T>.addFirstDistinct(item: T): List<T> {
|
||||||
(fragment.activity as? Owner)?.let {
|
val result = ArrayDeque<T>(this.size + 1)
|
||||||
return it.filterCoordinator
|
result.addAll(this)
|
||||||
}
|
if (item !in result) {
|
||||||
var f = fragment
|
result.addFirst(item)
|
||||||
while (true) {
|
}
|
||||||
(f as? Owner)?.let {
|
return result
|
||||||
return it.filterCoordinator
|
}
|
||||||
}
|
|
||||||
f = f.parentFragment ?: break
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun require(fragment: Fragment): FilterCoordinator {
|
data class Snapshot(
|
||||||
return find(fragment) ?: throw IllegalStateException("FilterCoordinator cannot be found")
|
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.isAnimationsEnabled
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding
|
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.filter.ui.model.FilterHeaderModel
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||||
@@ -28,69 +29,75 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsView.OnChipClickListener,
|
class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsView.OnChipClickListener,
|
||||||
ChipsView.OnChipCloseClickListener {
|
ChipsView.OnChipCloseClickListener {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var filterHeaderProducer: FilterHeaderProducer
|
lateinit var filterHeaderProducer: FilterHeaderProducer
|
||||||
|
|
||||||
private val filter: FilterCoordinator
|
private val filter: FilterCoordinator
|
||||||
get() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator
|
get() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator
|
||||||
|
|
||||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding {
|
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding {
|
||||||
return FragmentFilterHeaderBinding.inflate(inflater, container, false)
|
return FragmentFilterHeaderBinding.inflate(inflater, container, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) {
|
override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) {
|
||||||
super.onViewBindingCreated(binding, savedInstanceState)
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
binding.chipsTags.onChipClickListener = this
|
binding.chipsTags.onChipClickListener = this
|
||||||
binding.chipsTags.onChipCloseClickListener = this
|
binding.chipsTags.onChipCloseClickListener = this
|
||||||
filterHeaderProducer.observeHeader(filter)
|
filterHeaderProducer.observeHeader(filter)
|
||||||
.flowOn(Dispatchers.Default)
|
.flowOn(Dispatchers.Default)
|
||||||
.observe(viewLifecycleOwner, ::onDataChanged)
|
.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?) {
|
override fun onChipClick(chip: Chip, data: Any?) {
|
||||||
when (data) {
|
when (data) {
|
||||||
is MangaTag -> filter.toggleTag(data, !chip.isChecked)
|
is MangaTag -> filter.toggleTag(data, !chip.isChecked)
|
||||||
is String -> Unit
|
is PersistableFilter -> if (chip.isChecked) {
|
||||||
null -> router.showTagsCatalogSheet(excludeMode = false)
|
filter.reset()
|
||||||
}
|
} else {
|
||||||
}
|
filter.setAdjusted(data.filter)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onChipCloseClick(chip: Chip, data: Any?) {
|
is String -> Unit
|
||||||
when (data) {
|
null -> router.showTagsCatalogSheet(excludeMode = false)
|
||||||
is String -> if (data == filter.snapshot().listFilter.author) {
|
}
|
||||||
filter.setAuthor(null)
|
}
|
||||||
} else {
|
|
||||||
filter.setQuery(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
is ContentRating -> filter.toggleContentRating(data, false)
|
override fun onChipCloseClick(chip: Chip, data: Any?) {
|
||||||
is Demographic -> filter.toggleDemographic(data, false)
|
when (data) {
|
||||||
is ContentType -> filter.toggleContentType(data, false)
|
is String -> if (data == filter.snapshot().listFilter.author) {
|
||||||
is MangaState -> filter.toggleState(data, false)
|
filter.setAuthor(null)
|
||||||
is Locale -> filter.setLocale(null)
|
} else {
|
||||||
is Int -> filter.setYear(YEAR_UNKNOWN)
|
filter.setQuery(null)
|
||||||
is IntRange -> filter.setYearRange(YEAR_UNKNOWN, YEAR_UNKNOWN)
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onDataChanged(header: FilterHeaderModel) {
|
is ContentRating -> filter.toggleContentRating(data, false)
|
||||||
val binding = viewBinding ?: return
|
is Demographic -> filter.toggleDemographic(data, false)
|
||||||
val chips = header.chips
|
is ContentType -> filter.toggleContentType(data, false)
|
||||||
if (chips.isEmpty()) {
|
is MangaState -> filter.toggleState(data, false)
|
||||||
binding.chipsTags.setChips(emptyList())
|
is Locale -> filter.setLocale(null)
|
||||||
binding.root.isVisible = false
|
is Int -> filter.setYear(YEAR_UNKNOWN)
|
||||||
return
|
is IntRange -> filter.setYearRange(YEAR_UNKNOWN, YEAR_UNKNOWN)
|
||||||
}
|
}
|
||||||
binding.chipsTags.setChips(header.chips)
|
}
|
||||||
binding.root.isVisible = true
|
|
||||||
if (binding.root.context.isAnimationsEnabled) {
|
private fun onDataChanged(header: FilterHeaderModel) {
|
||||||
binding.scrollView.smoothScrollTo(0, 0)
|
val binding = viewBinding ?: return
|
||||||
} else {
|
val chips = header.chips
|
||||||
binding.scrollView.scrollTo(0, 0)
|
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.R
|
||||||
import org.koitharu.kotatsu.core.model.titleResId
|
import org.koitharu.kotatsu.core.model.titleResId
|
||||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
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.FilterHeaderModel
|
||||||
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
@@ -17,143 +18,162 @@ import javax.inject.Inject
|
|||||||
import androidx.appcompat.R as appcompatR
|
import androidx.appcompat.R as appcompatR
|
||||||
|
|
||||||
class FilterHeaderProducer @Inject constructor(
|
class FilterHeaderProducer @Inject constructor(
|
||||||
private val searchRepository: MangaSearchRepository,
|
private val searchRepository: MangaSearchRepository,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun observeHeader(filterCoordinator: FilterCoordinator): Flow<FilterHeaderModel> {
|
fun observeHeader(filterCoordinator: FilterCoordinator): Flow<FilterHeaderModel> {
|
||||||
return combine(filterCoordinator.tags, filterCoordinator.observe()) { tags, snapshot ->
|
return combine(
|
||||||
val chipList = createChipsList(
|
filterCoordinator.savedFilters,
|
||||||
source = filterCoordinator.mangaSource,
|
filterCoordinator.tags,
|
||||||
capabilities = filterCoordinator.capabilities,
|
filterCoordinator.observe(),
|
||||||
tagsProperty = tags,
|
) { saved, tags, snapshot ->
|
||||||
snapshot = snapshot.listFilter,
|
val chipList = createChipsList(
|
||||||
limit = 12,
|
source = filterCoordinator.mangaSource,
|
||||||
)
|
capabilities = filterCoordinator.capabilities,
|
||||||
FilterHeaderModel(
|
savedFilters = saved,
|
||||||
chips = chipList,
|
tagsProperty = tags,
|
||||||
sortOrder = snapshot.sortOrder,
|
snapshot = snapshot.listFilter,
|
||||||
isFilterApplied = !snapshot.listFilter.isEmpty(),
|
limit = 12,
|
||||||
)
|
)
|
||||||
}
|
FilterHeaderModel(
|
||||||
}
|
chips = chipList,
|
||||||
|
sortOrder = snapshot.sortOrder,
|
||||||
|
isFilterApplied = !snapshot.listFilter.isEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun createChipsList(
|
private suspend fun createChipsList(
|
||||||
source: MangaSource,
|
source: MangaSource,
|
||||||
capabilities: MangaListFilterCapabilities,
|
capabilities: MangaListFilterCapabilities,
|
||||||
tagsProperty: FilterProperty<MangaTag>,
|
savedFilters: FilterProperty<PersistableFilter>,
|
||||||
snapshot: MangaListFilter,
|
tagsProperty: FilterProperty<MangaTag>,
|
||||||
limit: Int,
|
snapshot: MangaListFilter,
|
||||||
): List<ChipsView.ChipModel> {
|
limit: Int,
|
||||||
val result = ArrayDeque<ChipsView.ChipModel>(limit + 3)
|
): List<ChipsView.ChipModel> {
|
||||||
if (snapshot.query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) {
|
val result = ArrayDeque<ChipsView.ChipModel>(savedFilters.availableItems.size + limit + 3)
|
||||||
val selectedTags = tagsProperty.selectedItems.toMutableSet()
|
if (snapshot.query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) {
|
||||||
var tags = if (selectedTags.isEmpty()) {
|
val selectedTags = tagsProperty.selectedItems.toMutableSet()
|
||||||
searchRepository.getTagsSuggestion("", limit, source)
|
var tags = if (selectedTags.isEmpty()) {
|
||||||
} else {
|
searchRepository.getTagsSuggestion("", limit, source)
|
||||||
searchRepository.getTagsSuggestion(selectedTags).take(limit)
|
} else {
|
||||||
}
|
searchRepository.getTagsSuggestion(selectedTags).take(limit)
|
||||||
if (tags.size < limit) {
|
}
|
||||||
tags = tags + tagsProperty.availableItems.take(limit - tags.size)
|
if (tags.size < limit) {
|
||||||
}
|
tags = tags + tagsProperty.availableItems.take(limit - tags.size)
|
||||||
if (tags.isEmpty() && selectedTags.isEmpty()) {
|
}
|
||||||
return emptyList()
|
if (tags.isEmpty() && selectedTags.isEmpty()) {
|
||||||
}
|
return emptyList()
|
||||||
for (tag in tags) {
|
}
|
||||||
val model = ChipsView.ChipModel(
|
for (saved in savedFilters.availableItems) {
|
||||||
title = tag.title,
|
val model = ChipsView.ChipModel(
|
||||||
isChecked = selectedTags.remove(tag),
|
title = saved.name,
|
||||||
data = tag,
|
isChecked = saved in savedFilters.selectedItems,
|
||||||
)
|
data = saved,
|
||||||
if (model.isChecked) {
|
)
|
||||||
result.addFirst(model)
|
if (model.isChecked) {
|
||||||
} else {
|
selectedTags.removeAll(saved.filter.tags)
|
||||||
result.addLast(model)
|
result.addFirst(model)
|
||||||
}
|
} else {
|
||||||
}
|
result.addLast(model)
|
||||||
for (tag in selectedTags) {
|
}
|
||||||
val model = ChipsView.ChipModel(
|
}
|
||||||
title = tag.title,
|
for (tag in tags) {
|
||||||
isChecked = true,
|
val model = ChipsView.ChipModel(
|
||||||
data = tag,
|
title = tag.title,
|
||||||
)
|
isChecked = selectedTags.remove(tag),
|
||||||
result.addFirst(model)
|
data = tag,
|
||||||
}
|
)
|
||||||
}
|
if (model.isChecked) {
|
||||||
snapshot.locale?.let {
|
result.addFirst(model)
|
||||||
result.addFirst(
|
} else {
|
||||||
ChipsView.ChipModel(
|
result.addLast(model)
|
||||||
title = it.getDisplayName(it).toTitleCase(it),
|
}
|
||||||
icon = R.drawable.ic_language,
|
}
|
||||||
isCloseable = true,
|
for (tag in selectedTags) {
|
||||||
data = it,
|
val model = ChipsView.ChipModel(
|
||||||
),
|
title = tag.title,
|
||||||
)
|
isChecked = true,
|
||||||
}
|
data = tag,
|
||||||
snapshot.types.forEach {
|
)
|
||||||
result.addFirst(
|
result.addFirst(model)
|
||||||
ChipsView.ChipModel(
|
}
|
||||||
titleResId = it.titleResId,
|
}
|
||||||
isCloseable = true,
|
snapshot.locale?.let {
|
||||||
data = it,
|
result.addFirst(
|
||||||
),
|
ChipsView.ChipModel(
|
||||||
)
|
title = it.getDisplayName(it).toTitleCase(it),
|
||||||
}
|
icon = R.drawable.ic_language,
|
||||||
snapshot.demographics.forEach {
|
isCloseable = true,
|
||||||
result.addFirst(
|
data = it,
|
||||||
ChipsView.ChipModel(
|
),
|
||||||
titleResId = it.titleResId,
|
)
|
||||||
isCloseable = true,
|
}
|
||||||
data = it,
|
snapshot.types.forEach {
|
||||||
),
|
result.addFirst(
|
||||||
)
|
ChipsView.ChipModel(
|
||||||
}
|
titleResId = it.titleResId,
|
||||||
snapshot.contentRating.forEach {
|
isCloseable = true,
|
||||||
result.addFirst(
|
data = it,
|
||||||
ChipsView.ChipModel(
|
),
|
||||||
titleResId = it.titleResId,
|
)
|
||||||
isCloseable = true,
|
}
|
||||||
data = it,
|
snapshot.demographics.forEach {
|
||||||
),
|
result.addFirst(
|
||||||
)
|
ChipsView.ChipModel(
|
||||||
}
|
titleResId = it.titleResId,
|
||||||
snapshot.states.forEach {
|
isCloseable = true,
|
||||||
result.addFirst(
|
data = it,
|
||||||
ChipsView.ChipModel(
|
),
|
||||||
titleResId = it.titleResId,
|
)
|
||||||
isCloseable = true,
|
}
|
||||||
data = it,
|
snapshot.contentRating.forEach {
|
||||||
),
|
result.addFirst(
|
||||||
)
|
ChipsView.ChipModel(
|
||||||
}
|
titleResId = it.titleResId,
|
||||||
if (!snapshot.query.isNullOrEmpty()) {
|
isCloseable = true,
|
||||||
result.addFirst(
|
data = it,
|
||||||
ChipsView.ChipModel(
|
),
|
||||||
title = snapshot.query,
|
)
|
||||||
icon = appcompatR.drawable.abc_ic_search_api_material,
|
}
|
||||||
isCloseable = true,
|
snapshot.states.forEach {
|
||||||
data = snapshot.query,
|
result.addFirst(
|
||||||
),
|
ChipsView.ChipModel(
|
||||||
)
|
titleResId = it.titleResId,
|
||||||
}
|
isCloseable = true,
|
||||||
if (!snapshot.author.isNullOrEmpty()) {
|
data = it,
|
||||||
result.addFirst(
|
),
|
||||||
ChipsView.ChipModel(
|
)
|
||||||
title = snapshot.author,
|
}
|
||||||
icon = R.drawable.ic_user,
|
if (!snapshot.query.isNullOrEmpty()) {
|
||||||
isCloseable = true,
|
result.addFirst(
|
||||||
data = snapshot.author,
|
ChipsView.ChipModel(
|
||||||
),
|
title = snapshot.query,
|
||||||
)
|
icon = appcompatR.drawable.abc_ic_search_api_material,
|
||||||
}
|
isCloseable = true,
|
||||||
val hasTags = result.any { it.data is MangaTag }
|
data = snapshot.query,
|
||||||
if (hasTags) {
|
),
|
||||||
result.addFirst(moreTagsChip())
|
)
|
||||||
}
|
}
|
||||||
return result
|
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(
|
private fun moreTagsChip() = ChipsView.ChipModel(
|
||||||
titleResId = R.string.genres,
|
titleResId = R.string.genres,
|
||||||
icon = R.drawable.ic_drawer_menu_open,
|
icon = R.drawable.ic_drawer_menu_open,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,39 @@
|
|||||||
package org.koitharu.kotatsu.filter.ui.sheet
|
package org.koitharu.kotatsu.filter.ui.sheet
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.text.InputFilter
|
||||||
|
import android.view.Gravity
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
import android.widget.AdapterView
|
import android.widget.AdapterView
|
||||||
import android.widget.ArrayAdapter
|
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.WindowInsetsCompat
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import com.google.android.material.chip.Chip
|
import com.google.android.material.chip.Chip
|
||||||
import com.google.android.material.slider.RangeSlider
|
import com.google.android.material.slider.RangeSlider
|
||||||
import com.google.android.material.slider.Slider
|
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.R
|
||||||
import org.koitharu.kotatsu.core.model.titleResId
|
import org.koitharu.kotatsu.core.model.titleResId
|
||||||
import org.koitharu.kotatsu.core.nav.router
|
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.model.titleRes
|
||||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
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.consume
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
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.setValueRounded
|
||||||
import org.koitharu.kotatsu.core.util.ext.setValuesRounded
|
import org.koitharu.kotatsu.core.util.ext.setValuesRounded
|
||||||
import org.koitharu.kotatsu.databinding.SheetFilterBinding
|
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.FilterCoordinator
|
||||||
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
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.MangaTag
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN
|
import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
import org.koitharu.kotatsu.parsers.util.toIntUp
|
import org.koitharu.kotatsu.parsers.util.toIntUp
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.TreeSet
|
||||||
|
|
||||||
class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||||
AdapterView.OnItemSelectedListener,
|
AdapterView.OnItemSelectedListener,
|
||||||
ChipsView.OnChipClickListener {
|
View.OnClickListener,
|
||||||
|
ChipsView.OnChipClickListener,
|
||||||
|
ChipsView.OnChipLongClickListener,
|
||||||
|
ChipsView.OnChipCloseClickListener {
|
||||||
|
|
||||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
|
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
|
||||||
return SheetFilterBinding.inflate(inflater, container, false)
|
return SheetFilterBinding.inflate(inflater, container, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
|
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
|
||||||
super.onViewBindingCreated(binding, savedInstanceState)
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
if (dialog == null) {
|
if (dialog == null) {
|
||||||
binding.layoutBody.updatePadding(top = binding.layoutBody.paddingBottom)
|
binding.adjustForEmbeddedLayout()
|
||||||
binding.scrollView.scrollIndicators = 0
|
}
|
||||||
}
|
val filter = FilterCoordinator.require(this)
|
||||||
val filter = FilterCoordinator.require(this)
|
filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
|
||||||
filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
|
filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged)
|
||||||
filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged)
|
filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged)
|
||||||
filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged)
|
filter.tags.observe(viewLifecycleOwner, this::onTagsChanged)
|
||||||
filter.tags.observe(viewLifecycleOwner, this::onTagsChanged)
|
filter.tagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged)
|
||||||
filter.tagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged)
|
filter.authors.observe(viewLifecycleOwner, this::onAuthorsChanged)
|
||||||
filter.states.observe(viewLifecycleOwner, this::onStateChanged)
|
filter.states.observe(viewLifecycleOwner, this::onStateChanged)
|
||||||
filter.contentTypes.observe(viewLifecycleOwner, this::onContentTypesChanged)
|
filter.contentTypes.observe(viewLifecycleOwner, this::onContentTypesChanged)
|
||||||
filter.contentRating.observe(viewLifecycleOwner, this::onContentRatingChanged)
|
filter.contentRating.observe(viewLifecycleOwner, this::onContentRatingChanged)
|
||||||
filter.demographics.observe(viewLifecycleOwner, this::onDemographicsChanged)
|
filter.demographics.observe(viewLifecycleOwner, this::onDemographicsChanged)
|
||||||
filter.year.observe(viewLifecycleOwner, this::onYearChanged)
|
filter.year.observe(viewLifecycleOwner, this::onYearChanged)
|
||||||
filter.yearRange.observe(viewLifecycleOwner, this::onYearRangeChanged)
|
filter.yearRange.observe(viewLifecycleOwner, this::onYearRangeChanged)
|
||||||
|
filter.savedFilters.observe(viewLifecycleOwner, ::onSavedPresetsChanged)
|
||||||
|
|
||||||
binding.layoutGenres.setTitle(
|
binding.layoutGenres.setTitle(
|
||||||
if (filter.capabilities.isMultipleTagsSupported) {
|
if (filter.capabilities.isMultipleTagsSupported) {
|
||||||
R.string.genres
|
R.string.genres
|
||||||
} else {
|
} else {
|
||||||
R.string.genre
|
R.string.genre
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
binding.spinnerLocale.onItemSelectedListener = this
|
binding.spinnerLocale.onItemSelectedListener = this
|
||||||
binding.spinnerOriginalLocale.onItemSelectedListener = this
|
binding.spinnerOriginalLocale.onItemSelectedListener = this
|
||||||
binding.spinnerOrder.onItemSelectedListener = this
|
binding.spinnerOrder.onItemSelectedListener = this
|
||||||
binding.chipsState.onChipClickListener = this
|
binding.chipsSavedFilters.onChipClickListener = this
|
||||||
binding.chipsTypes.onChipClickListener = this
|
binding.chipsState.onChipClickListener = this
|
||||||
binding.chipsContentRating.onChipClickListener = this
|
binding.chipsTypes.onChipClickListener = this
|
||||||
binding.chipsDemographics.onChipClickListener = this
|
binding.chipsContentRating.onChipClickListener = this
|
||||||
binding.chipsGenres.onChipClickListener = this
|
binding.chipsDemographics.onChipClickListener = this
|
||||||
binding.chipsGenresExclude.onChipClickListener = this
|
binding.chipsGenres.onChipClickListener = this
|
||||||
binding.sliderYear.addOnChangeListener(this::onSliderValueChange)
|
binding.chipsGenresExclude.onChipClickListener = this
|
||||||
binding.sliderYearsRange.addOnChangeListener(this::onRangeSliderValueChange)
|
binding.chipsAuthor.onChipClickListener = this
|
||||||
binding.layoutGenres.setOnMoreButtonClickListener {
|
binding.chipsSavedFilters.onChipLongClickListener = this
|
||||||
router.showTagsCatalogSheet(excludeMode = false)
|
binding.chipsSavedFilters.onChipCloseClickListener = this
|
||||||
}
|
binding.sliderYear.addOnChangeListener(this::onSliderValueChange)
|
||||||
binding.layoutGenresExclude.setOnMoreButtonClickListener {
|
binding.sliderYearsRange.addOnChangeListener(this::onRangeSliderValueChange)
|
||||||
router.showTagsCatalogSheet(excludeMode = true)
|
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 {
|
private fun SheetFilterBinding.adjustForEmbeddedLayout() {
|
||||||
val typeMask = WindowInsetsCompat.Type.systemBars()
|
layoutBody.updatePadding(top = layoutBody.paddingBottom)
|
||||||
viewBinding?.scrollView?.updatePadding(
|
scrollView.scrollIndicators = 0
|
||||||
bottom = insets.getInsets(typeMask).bottom,
|
buttonDone.isVisible = false
|
||||||
)
|
this.root.layoutParams?.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
return insets.consume(v, typeMask, bottom = true)
|
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) {
|
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||||
val filter = FilterCoordinator.require(this)
|
val typeMask = WindowInsetsCompat.Type.systemBars()
|
||||||
when (parent.id) {
|
viewBinding?.layoutBottom?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||||
R.id.spinner_order -> filter.setSortOrder(filter.sortOrder.value.availableItems[position])
|
bottomMargin = insets.getInsets(typeMask).bottom
|
||||||
R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position])
|
}
|
||||||
R.id.spinner_original_locale -> filter.setOriginalLocale(filter.originalLocale.value.availableItems[position])
|
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) {
|
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
|
||||||
if (!fromUser) {
|
val filter = FilterCoordinator.require(this)
|
||||||
return
|
when (parent.id) {
|
||||||
}
|
R.id.spinner_order -> filter.setSortOrder(filter.sortOrder.value.availableItems[position])
|
||||||
val intValue = value.toInt()
|
R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position])
|
||||||
val filter = FilterCoordinator.require(this)
|
R.id.spinner_original_locale -> filter.setOriginalLocale(filter.originalLocale.value.availableItems[position])
|
||||||
when (slider.id) {
|
}
|
||||||
R.id.slider_year -> filter.setYear(
|
}
|
||||||
if (intValue <= slider.valueFrom.toIntUp()) {
|
|
||||||
YEAR_UNKNOWN
|
|
||||||
} else {
|
|
||||||
intValue
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onRangeSliderValueChange(slider: RangeSlider, value: Float, fromUser: Boolean) {
|
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||||
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 onChipClick(chip: Chip, data: Any?) {
|
private fun onSliderValueChange(slider: Slider, value: Float, fromUser: Boolean) {
|
||||||
val filter = FilterCoordinator.require(this)
|
if (!fromUser) {
|
||||||
when (data) {
|
return
|
||||||
is MangaState -> filter.toggleState(data, !chip.isChecked)
|
}
|
||||||
is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) {
|
val intValue = value.toInt()
|
||||||
filter.toggleTagExclude(data, !chip.isChecked)
|
val filter = FilterCoordinator.require(this)
|
||||||
} else {
|
when (slider.id) {
|
||||||
filter.toggleTag(data, !chip.isChecked)
|
R.id.slider_year -> filter.setYear(
|
||||||
}
|
if (intValue <= slider.valueFrom.toIntUp()) {
|
||||||
|
YEAR_UNKNOWN
|
||||||
|
} else {
|
||||||
|
intValue
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
is ContentType -> filter.toggleContentType(data, !chip.isChecked)
|
private fun onRangeSliderValueChange(slider: RangeSlider, value: Float, fromUser: Boolean) {
|
||||||
is ContentRating -> filter.toggleContentRating(data, !chip.isChecked)
|
if (!fromUser) {
|
||||||
is Demographic -> filter.toggleDemographic(data, !chip.isChecked)
|
return
|
||||||
null -> router.showTagsCatalogSheet(excludeMode = chip.parentView?.id == R.id.chips_genresExclude)
|
}
|
||||||
}
|
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>) {
|
override fun onChipClick(chip: Chip, data: Any?) {
|
||||||
val b = viewBinding ?: return
|
val filter = FilterCoordinator.require(this)
|
||||||
b.layoutOrder.isGone = value.isEmpty()
|
when (data) {
|
||||||
if (value.isEmpty()) {
|
is MangaState -> filter.toggleState(data, !chip.isChecked)
|
||||||
return
|
is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) {
|
||||||
}
|
filter.toggleTagExclude(data, !chip.isChecked)
|
||||||
val selected = value.selectedItems.single()
|
} else {
|
||||||
b.spinnerOrder.adapter = ArrayAdapter(
|
filter.toggleTag(data, !chip.isChecked)
|
||||||
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 onLocaleChanged(value: FilterProperty<Locale?>) {
|
is ContentType -> filter.toggleContentType(data, !chip.isChecked)
|
||||||
val b = viewBinding ?: return
|
is ContentRating -> filter.toggleContentRating(data, !chip.isChecked)
|
||||||
b.layoutLocale.isGone = value.isEmpty()
|
is Demographic -> filter.toggleDemographic(data, !chip.isChecked)
|
||||||
if (value.isEmpty()) {
|
is PersistableFilter -> filter.setAdjusted(data.filter)
|
||||||
return
|
is String -> if (chip.isChecked) {
|
||||||
}
|
filter.setAuthor(null)
|
||||||
val selected = value.selectedItems.singleOrNull()
|
} else {
|
||||||
b.spinnerLocale.adapter = ArrayAdapter(
|
filter.setAuthor(data)
|
||||||
b.spinnerLocale.context,
|
}
|
||||||
android.R.layout.simple_spinner_dropdown_item,
|
null -> router.showTagsCatalogSheet(excludeMode = chip.parentView?.id == R.id.chips_genresExclude)
|
||||||
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 onOriginalLocaleChanged(value: FilterProperty<Locale?>) {
|
override fun onChipLongClick(chip: Chip, data: Any?): Boolean {
|
||||||
val b = viewBinding ?: return
|
return when (data) {
|
||||||
b.layoutOriginalLocale.isGone = value.isEmpty()
|
is PersistableFilter -> {
|
||||||
if (value.isEmpty()) {
|
showSavedFilterMenu(chip, data)
|
||||||
return
|
true
|
||||||
}
|
}
|
||||||
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 onTagsChanged(value: FilterProperty<MangaTag>) {
|
else -> false
|
||||||
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 onTagsExcludedChanged(value: FilterProperty<MangaTag>) {
|
override fun onChipCloseClick(chip: Chip, data: Any?) {
|
||||||
val b = viewBinding ?: return
|
when (data) {
|
||||||
b.layoutGenresExclude.isGone = value.isEmpty()
|
is PersistableFilter -> {
|
||||||
if (value.isEmpty()) {
|
showSavedFilterMenu(chip, data)
|
||||||
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 onStateChanged(value: FilterProperty<MangaState>) {
|
private fun onSortOrderChanged(value: FilterProperty<SortOrder>) {
|
||||||
val b = viewBinding ?: return
|
val b = viewBinding ?: return
|
||||||
b.layoutState.isGone = value.isEmpty()
|
b.layoutOrder.isGone = value.isEmpty()
|
||||||
if (value.isEmpty()) {
|
if (value.isEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val chips = value.availableItems.map { state ->
|
val selected = value.selectedItems.single()
|
||||||
ChipsView.ChipModel(
|
b.spinnerOrder.adapter = ArrayAdapter(
|
||||||
title = getString(state.titleResId),
|
b.spinnerOrder.context,
|
||||||
isChecked = state in value.selectedItems,
|
android.R.layout.simple_spinner_dropdown_item,
|
||||||
data = state,
|
android.R.id.text1,
|
||||||
)
|
value.availableItems.map { b.spinnerOrder.context.getString(it.titleRes) },
|
||||||
}
|
)
|
||||||
b.chipsState.setChips(chips)
|
val selectedIndex = value.availableItems.indexOf(selected)
|
||||||
}
|
if (selectedIndex >= 0) {
|
||||||
|
b.spinnerOrder.setSelection(selectedIndex, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun onContentTypesChanged(value: FilterProperty<ContentType>) {
|
private fun onLocaleChanged(value: FilterProperty<Locale?>) {
|
||||||
val b = viewBinding ?: return
|
val b = viewBinding ?: return
|
||||||
b.layoutTypes.isGone = value.isEmpty()
|
b.layoutLocale.isGone = value.isEmpty()
|
||||||
if (value.isEmpty()) {
|
if (value.isEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val chips = value.availableItems.map { type ->
|
val selected = value.selectedItems.singleOrNull()
|
||||||
ChipsView.ChipModel(
|
b.spinnerLocale.adapter = ArrayAdapter(
|
||||||
title = getString(type.titleResId),
|
b.spinnerLocale.context,
|
||||||
isChecked = type in value.selectedItems,
|
android.R.layout.simple_spinner_dropdown_item,
|
||||||
data = type,
|
android.R.id.text1,
|
||||||
)
|
value.availableItems.map { it.getDisplayName(b.spinnerLocale.context) },
|
||||||
}
|
)
|
||||||
b.chipsTypes.setChips(chips)
|
val selectedIndex = value.availableItems.indexOf(selected)
|
||||||
}
|
if (selectedIndex >= 0) {
|
||||||
|
b.spinnerLocale.setSelection(selectedIndex, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun onContentRatingChanged(value: FilterProperty<ContentRating>) {
|
private fun onOriginalLocaleChanged(value: FilterProperty<Locale?>) {
|
||||||
val b = viewBinding ?: return
|
val b = viewBinding ?: return
|
||||||
b.layoutContentRating.isGone = value.isEmpty()
|
b.layoutOriginalLocale.isGone = value.isEmpty()
|
||||||
if (value.isEmpty()) {
|
if (value.isEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val chips = value.availableItems.map { contentRating ->
|
val selected = value.selectedItems.singleOrNull()
|
||||||
ChipsView.ChipModel(
|
b.spinnerOriginalLocale.adapter = ArrayAdapter(
|
||||||
title = getString(contentRating.titleResId),
|
b.spinnerOriginalLocale.context,
|
||||||
isChecked = contentRating in value.selectedItems,
|
android.R.layout.simple_spinner_dropdown_item,
|
||||||
data = contentRating,
|
android.R.id.text1,
|
||||||
)
|
value.availableItems.map { it.getDisplayName(b.spinnerOriginalLocale.context) },
|
||||||
}
|
)
|
||||||
b.chipsContentRating.setChips(chips)
|
val selectedIndex = value.availableItems.indexOf(selected)
|
||||||
}
|
if (selectedIndex >= 0) {
|
||||||
|
b.spinnerOriginalLocale.setSelection(selectedIndex, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun onDemographicsChanged(value: FilterProperty<Demographic>) {
|
private fun onTagsChanged(value: FilterProperty<MangaTag>) {
|
||||||
val b = viewBinding ?: return
|
val b = viewBinding ?: return
|
||||||
b.layoutDemographics.isGone = value.isEmpty()
|
b.layoutGenres.isGone = value.isEmptyAndSuccess()
|
||||||
if (value.isEmpty()) {
|
b.layoutGenres.setError(value.error?.getDisplayMessage(resources))
|
||||||
return
|
if (value.isEmpty()) {
|
||||||
}
|
return
|
||||||
val chips = value.availableItems.map { demographic ->
|
}
|
||||||
ChipsView.ChipModel(
|
val chips = value.availableItems.map { tag ->
|
||||||
title = getString(demographic.titleResId),
|
ChipsView.ChipModel(
|
||||||
isChecked = demographic in value.selectedItems,
|
title = tag.title,
|
||||||
data = demographic,
|
isChecked = tag in value.selectedItems,
|
||||||
)
|
data = tag,
|
||||||
}
|
)
|
||||||
b.chipsDemographics.setChips(chips)
|
}
|
||||||
}
|
b.chipsGenres.setChips(chips)
|
||||||
|
}
|
||||||
|
|
||||||
private fun onYearChanged(value: FilterProperty<Int>) {
|
private fun onTagsExcludedChanged(value: FilterProperty<MangaTag>) {
|
||||||
val b = viewBinding ?: return
|
val b = viewBinding ?: return
|
||||||
b.layoutYear.isGone = value.isEmpty()
|
b.layoutGenresExclude.isGone = value.isEmpty()
|
||||||
if (value.isEmpty()) {
|
if (value.isEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val currentValue = value.selectedItems.singleOrNull() ?: YEAR_UNKNOWN
|
val chips = value.availableItems.map { tag ->
|
||||||
b.layoutYear.setValueText(
|
ChipsView.ChipModel(
|
||||||
if (currentValue == YEAR_UNKNOWN) {
|
title = tag.title,
|
||||||
getString(R.string.any)
|
isChecked = tag in value.selectedItems,
|
||||||
} else {
|
data = tag,
|
||||||
currentValue.toString()
|
)
|
||||||
},
|
}
|
||||||
)
|
b.chipsGenresExclude.setChips(chips)
|
||||||
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>) {
|
private fun onAuthorsChanged(value: FilterProperty<String>) {
|
||||||
val b = viewBinding ?: return
|
val b = viewBinding ?: return
|
||||||
b.layoutYearsRange.isGone = value.isEmpty()
|
b.layoutAuthor.isGone = value.isEmpty()
|
||||||
if (value.isEmpty()) {
|
if (value.isEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
b.sliderYearsRange.valueFrom = value.availableItems.first().toFloat()
|
val chips = value.availableItems.map { author ->
|
||||||
b.sliderYearsRange.valueTo = value.availableItems.last().toFloat()
|
ChipsView.ChipModel(
|
||||||
val currentValueFrom = value.selectedItems.firstOrNull()?.toFloat() ?: b.sliderYearsRange.valueFrom
|
title = author,
|
||||||
val currentValueTo = value.selectedItems.lastOrNull()?.toFloat() ?: b.sliderYearsRange.valueTo
|
isChecked = author in value.selectedItems,
|
||||||
b.layoutYearsRange.setValueText(
|
data = author,
|
||||||
getString(
|
)
|
||||||
R.string.memory_usage_pattern,
|
}
|
||||||
currentValueFrom.toInt().toString(),
|
b.chipsAuthor.setChips(chips)
|
||||||
currentValueTo.toInt().toString(),
|
}
|
||||||
),
|
|
||||||
)
|
private fun onStateChanged(value: FilterProperty<MangaState>) {
|
||||||
b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo)
|
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.content.Context
|
||||||
import android.graphics.drawable.LayerDrawable
|
import android.graphics.drawable.LayerDrawable
|
||||||
import android.os.Build
|
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.ViewTreeObserver
|
import android.view.ViewTreeObserver
|
||||||
import android.view.ViewTreeObserver.OnPreDrawListener
|
import android.view.ViewTreeObserver.OnPreDrawListener
|
||||||
import androidx.annotation.AttrRes
|
import androidx.annotation.AttrRes
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.withStyledAttributes
|
import androidx.core.content.withStyledAttributes
|
||||||
import androidx.core.graphics.ColorUtils
|
import androidx.core.graphics.ColorUtils
|
||||||
@@ -205,6 +203,7 @@ class CoverImageView @JvmOverloads constructor(
|
|||||||
is HttpStatusException -> statusCode.toString()
|
is HttpStatusException -> statusCode.toString()
|
||||||
is ContentUnavailableException,
|
is ContentUnavailableException,
|
||||||
is FileNotFoundException -> "404"
|
is FileNotFoundException -> "404"
|
||||||
|
|
||||||
is TooManyRequestExceptions -> "429"
|
is TooManyRequestExceptions -> "429"
|
||||||
is ParseException -> "</>"
|
is ParseException -> "</>"
|
||||||
is UnsupportedSourceException -> "X"
|
is UnsupportedSourceException -> "X"
|
||||||
@@ -266,7 +265,7 @@ class CoverImageView @JvmOverloads constructor(
|
|||||||
width = Dimension(height.px * view.aspectRationWidth / view.aspectRationHeight)
|
width = Dimension(height.px * view.aspectRationWidth / view.aspectRationHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Size(checkNotNull(width), checkNotNull(height))
|
return Size(width, height)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getWidth() = getDimension(
|
private fun getWidth() = getDimension(
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ import androidx.annotation.WorkerThread
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.net.toFile
|
import androidx.core.net.toFile
|
||||||
import dagger.Reusable
|
import dagger.Reusable
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
|
import org.koitharu.kotatsu.core.LocalizedAppContext
|
||||||
import org.koitharu.kotatsu.core.exceptions.NonFileUriException
|
import org.koitharu.kotatsu.core.exceptions.NonFileUriException
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.util.ext.computeSize
|
import org.koitharu.kotatsu.core.util.ext.computeSize
|
||||||
@@ -39,8 +39,8 @@ private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
|
|||||||
|
|
||||||
@Reusable
|
@Reusable
|
||||||
class LocalStorageManager @Inject constructor(
|
class LocalStorageManager @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@LocalizedAppContext private val context: Context,
|
||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val contentResolver: ContentResolver
|
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.getStringOrNull
|
||||||
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
|
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
|
||||||
import org.koitharu.kotatsu.parsers.util.json.toStringSet
|
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.runCatchingCancellable
|
||||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ class LocalMangaParser(private val uri: Uri) {
|
|||||||
val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX)
|
val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX)
|
||||||
val mangaInfo = index?.getMangaInfo()
|
val mangaInfo = index?.getMangaInfo()
|
||||||
if (mangaInfo != null) {
|
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(
|
mangaInfo.copy(
|
||||||
source = LocalMangaSource,
|
source = LocalMangaSource,
|
||||||
url = rootFile.toUri().toString(),
|
url = rootFile.toUri().toString(),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package org.koitharu.kotatsu.main.ui
|
package org.koitharu.kotatsu.main.ui
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.app.BackgroundServiceStartNotAllowedException
|
||||||
|
import android.app.ServiceStartNotAllowedException
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
import android.os.Build
|
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.end
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.core.util.ext.start
|
import org.koitharu.kotatsu.core.util.ext.start
|
||||||
import org.koitharu.kotatsu.databinding.ActivityMainBinding
|
import org.koitharu.kotatsu.databinding.ActivityMainBinding
|
||||||
import org.koitharu.kotatsu.details.service.MangaPrefetchService
|
import org.koitharu.kotatsu.details.service.MangaPrefetchService
|
||||||
@@ -288,7 +291,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
|
|||||||
adjustFabVisibility(isResumeEnabled = isEnabled)
|
adjustFabVisibility(isResumeEnabled = isEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onFirstStart() {
|
private fun onFirstStart() = try {
|
||||||
lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher
|
lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
LocalStorageCleanupWorker.enqueue(applicationContext)
|
LocalStorageCleanupWorker.enqueue(applicationContext)
|
||||||
@@ -303,6 +306,8 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun adjustAppbar(topFragment: Fragment) {
|
private fun adjustAppbar(topFragment: Fragment) {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipC
|
|||||||
binding.chipsType.onChipClickListener = this
|
binding.chipsType.onChipClickListener = this
|
||||||
binding.chipBackup.setOnClickListener(this)
|
binding.chipBackup.setOnClickListener(this)
|
||||||
binding.chipSync.setOnClickListener(this)
|
binding.chipSync.setOnClickListener(this)
|
||||||
|
binding.chipDirectories.setOnClickListener(this)
|
||||||
|
|
||||||
viewModel.locales.observe(viewLifecycleOwner, ::onLocalesChanged)
|
viewModel.locales.observe(viewLifecycleOwner, ::onLocalesChanged)
|
||||||
viewModel.types.observe(viewLifecycleOwner, ::onTypesChanged)
|
viewModel.types.observe(viewLifecycleOwner, ::onTypesChanged)
|
||||||
@@ -86,6 +87,10 @@ class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipC
|
|||||||
val accountType = getString(R.string.account_type_sync)
|
val accountType = getString(R.string.account_type_sync)
|
||||||
am.addAccount(accountType, accountType, null, null, requireActivity(), null, null)
|
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
|
package org.koitharu.kotatsu.reader.domain
|
||||||
|
|
||||||
import android.util.LongSparseArray
|
import android.util.LongSparseArray
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
import dagger.hilt.android.scopes.ViewModelScoped
|
import dagger.hilt.android.scopes.ViewModelScoped
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
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 chapters = manga.allChapters
|
||||||
val predicate: (MangaChapter) -> Boolean = { it.id == currentId }
|
val predicate: (MangaChapter) -> Boolean = { it.id == currentId }
|
||||||
val index = if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate)
|
val index = if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate)
|
||||||
if (index == -1) return
|
if (index == -1) return false
|
||||||
val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return
|
val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return false
|
||||||
val newPages = loadChapter(newChapter.id)
|
val newPages = loadChapter(newChapter.id)
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
if (chapterPages.chaptersSize > 1) {
|
if (chapterPages.chaptersSize > 1) {
|
||||||
@@ -56,13 +57,16 @@ class ChaptersLoader @Inject constructor(
|
|||||||
chapterPages.addFirst(newChapter.id, newPages)
|
chapterPages.addFirst(newChapter.id, newPages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun loadSingleChapter(chapterId: Long) {
|
@CheckResult
|
||||||
|
suspend fun loadSingleChapter(chapterId: Long): Boolean {
|
||||||
val pages = loadChapter(chapterId)
|
val pages = loadChapter(chapterId)
|
||||||
mutex.withLock {
|
return mutex.withLock {
|
||||||
chapterPages.clear()
|
chapterPages.clear()
|
||||||
chapterPages.addLast(chapterId, pages)
|
chapterPages.addLast(chapterId, pages)
|
||||||
|
pages.isNotEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,9 @@ import android.graphics.Rect
|
|||||||
import androidx.annotation.ColorInt
|
import androidx.annotation.ColorInt
|
||||||
import androidx.core.graphics.alpha
|
import androidx.core.graphics.alpha
|
||||||
import androidx.core.graphics.blue
|
import androidx.core.graphics.blue
|
||||||
import androidx.core.graphics.get
|
|
||||||
import androidx.core.graphics.green
|
import androidx.core.graphics.green
|
||||||
import androidx.core.graphics.red
|
import androidx.core.graphics.red
|
||||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||||
import com.davemorrissey.labs.subscaleview.decoder.ImageRegionDecoder
|
|
||||||
import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder
|
import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -23,7 +21,6 @@ import kotlinx.coroutines.sync.Mutex
|
|||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.koitharu.kotatsu.core.util.SynchronizedSieveCache
|
import org.koitharu.kotatsu.core.util.SynchronizedSieveCache
|
||||||
import org.koitharu.kotatsu.core.util.ext.use
|
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@@ -46,19 +43,19 @@ class EdgeDetector(private val context: Context) {
|
|||||||
}
|
}
|
||||||
val scaleFactor = calculateScaleFactor(size)
|
val scaleFactor = calculateScaleFactor(size)
|
||||||
val sampleSize = (1f / scaleFactor).toInt().coerceAtLeast(1)
|
val sampleSize = (1f / scaleFactor).toInt().coerceAtLeast(1)
|
||||||
|
|
||||||
val fullBitmap = decoder.decodeRegion(
|
val fullBitmap = decoder.decodeRegion(
|
||||||
Rect(0, 0, size.x, size.y),
|
Rect(0, 0, size.x, size.y),
|
||||||
sampleSize
|
sampleSize,
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val edges = coroutineScope {
|
val edges = coroutineScope {
|
||||||
listOf(
|
listOf(
|
||||||
async { detectLeftRightEdge(fullBitmap, size, sampleSize, isLeft = true) },
|
async { detectLeftRightEdge(fullBitmap, size, sampleSize, isLeft = true) },
|
||||||
async { detectTopBottomEdge(fullBitmap, size, sampleSize, isTop = true) },
|
async { detectTopBottomEdge(fullBitmap, size, sampleSize, isTop = true) },
|
||||||
async { detectLeftRightEdge(fullBitmap, size, sampleSize, isLeft = false) },
|
async { detectLeftRightEdge(fullBitmap, size, sampleSize, isLeft = false) },
|
||||||
async { detectTopBottomEdge(fullBitmap, size, sampleSize, isTop = false) },
|
async { detectTopBottomEdge(fullBitmap, size, sampleSize, isTop = false) },
|
||||||
).awaitAll()
|
).awaitAll()
|
||||||
}
|
}
|
||||||
var hasEdges = false
|
var hasEdges = false
|
||||||
@@ -91,10 +88,10 @@ class EdgeDetector(private val context: Context) {
|
|||||||
val rectCount = size.x / BLOCK_SIZE
|
val rectCount = size.x / BLOCK_SIZE
|
||||||
val maxRect = rectCount / 3
|
val maxRect = rectCount / 3
|
||||||
val blockPixels = IntArray(BLOCK_SIZE * BLOCK_SIZE)
|
val blockPixels = IntArray(BLOCK_SIZE * BLOCK_SIZE)
|
||||||
|
|
||||||
val bitmapWidth = bitmap.width
|
val bitmapWidth = bitmap.width
|
||||||
val bitmapHeight = bitmap.height
|
val bitmapHeight = bitmap.height
|
||||||
|
|
||||||
for (i in 0 until rectCount) {
|
for (i in 0 until rectCount) {
|
||||||
if (i > maxRect) {
|
if (i > maxRect) {
|
||||||
return -1
|
return -1
|
||||||
@@ -103,16 +100,16 @@ class EdgeDetector(private val context: Context) {
|
|||||||
for (j in 0 until size.y / BLOCK_SIZE) {
|
for (j in 0 until size.y / BLOCK_SIZE) {
|
||||||
val regionX = if (isLeft) i * BLOCK_SIZE else size.x - (i + 1) * BLOCK_SIZE
|
val regionX = if (isLeft) i * BLOCK_SIZE else size.x - (i + 1) * BLOCK_SIZE
|
||||||
val regionY = j * BLOCK_SIZE
|
val regionY = j * BLOCK_SIZE
|
||||||
|
|
||||||
// Convert to bitmap coordinates
|
// Convert to bitmap coordinates
|
||||||
val bitmapX = regionX / sampleSize
|
val bitmapX = regionX / sampleSize
|
||||||
val bitmapY = regionY / sampleSize
|
val bitmapY = regionY / sampleSize
|
||||||
val blockWidth = min(BLOCK_SIZE / sampleSize, bitmapWidth - bitmapX)
|
val blockWidth = min(BLOCK_SIZE / sampleSize, bitmapWidth - bitmapX)
|
||||||
val blockHeight = min(BLOCK_SIZE / sampleSize, bitmapHeight - bitmapY)
|
val blockHeight = min(BLOCK_SIZE / sampleSize, bitmapHeight - bitmapY)
|
||||||
|
|
||||||
if (blockWidth > 0 && blockHeight > 0) {
|
if (blockWidth > 0 && blockHeight > 0) {
|
||||||
bitmap.getPixels(blockPixels, 0, blockWidth, bitmapX, bitmapY, blockWidth, blockHeight)
|
bitmap.getPixels(blockPixels, 0, blockWidth, bitmapX, bitmapY, blockWidth, blockHeight)
|
||||||
|
|
||||||
for (ii in 0 until minOf(blockWidth, dd / sampleSize)) {
|
for (ii in 0 until minOf(blockWidth, dd / sampleSize)) {
|
||||||
for (jj in 0 until blockHeight) {
|
for (jj in 0 until blockHeight) {
|
||||||
val bi = if (isLeft) ii else blockWidth - ii - 1
|
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 rectCount = size.y / BLOCK_SIZE
|
||||||
val maxRect = rectCount / 3
|
val maxRect = rectCount / 3
|
||||||
val blockPixels = IntArray(BLOCK_SIZE * BLOCK_SIZE)
|
val blockPixels = IntArray(BLOCK_SIZE * BLOCK_SIZE)
|
||||||
|
|
||||||
val bitmapWidth = bitmap.width
|
val bitmapWidth = bitmap.width
|
||||||
val bitmapHeight = bitmap.height
|
val bitmapHeight = bitmap.height
|
||||||
|
|
||||||
for (j in 0 until rectCount) {
|
for (j in 0 until rectCount) {
|
||||||
if (j > maxRect) {
|
if (j > maxRect) {
|
||||||
return -1
|
return -1
|
||||||
@@ -153,16 +150,16 @@ class EdgeDetector(private val context: Context) {
|
|||||||
for (i in 0 until size.x / BLOCK_SIZE) {
|
for (i in 0 until size.x / BLOCK_SIZE) {
|
||||||
val regionX = i * BLOCK_SIZE
|
val regionX = i * BLOCK_SIZE
|
||||||
val regionY = if (isTop) j * BLOCK_SIZE else size.y - (j + 1) * BLOCK_SIZE
|
val regionY = if (isTop) j * BLOCK_SIZE else size.y - (j + 1) * BLOCK_SIZE
|
||||||
|
|
||||||
// Convert to bitmap coordinates
|
// Convert to bitmap coordinates
|
||||||
val bitmapX = regionX / sampleSize
|
val bitmapX = regionX / sampleSize
|
||||||
val bitmapY = regionY / sampleSize
|
val bitmapY = regionY / sampleSize
|
||||||
val blockWidth = min(BLOCK_SIZE / sampleSize, bitmapWidth - bitmapX)
|
val blockWidth = min(BLOCK_SIZE / sampleSize, bitmapWidth - bitmapX)
|
||||||
val blockHeight = min(BLOCK_SIZE / sampleSize, bitmapHeight - bitmapY)
|
val blockHeight = min(BLOCK_SIZE / sampleSize, bitmapHeight - bitmapY)
|
||||||
|
|
||||||
if (blockWidth > 0 && blockHeight > 0) {
|
if (blockWidth > 0 && blockHeight > 0) {
|
||||||
bitmap.getPixels(blockPixels, 0, blockWidth, bitmapX, bitmapY, blockWidth, blockHeight)
|
bitmap.getPixels(blockPixels, 0, blockWidth, bitmapX, bitmapY, blockWidth, blockHeight)
|
||||||
|
|
||||||
for (jj in 0 until minOf(blockHeight, dd / sampleSize)) {
|
for (jj in 0 until minOf(blockHeight, dd / sampleSize)) {
|
||||||
for (ii in 0 until blockWidth) {
|
for (ii in 0 until blockWidth) {
|
||||||
val bj = if (isTop) jj else blockHeight - jj - 1
|
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)
|
private fun region(x: Int, y: Int) = Rect(x, y, x + BLOCK_SIZE, y + BLOCK_SIZE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -488,7 +488,11 @@ class ReaderActivity :
|
|||||||
uiState.incognito -> getString(R.string.incognito_mode)
|
uiState.incognito -> getString(R.string.incognito_mode)
|
||||||
else -> chapterTitle
|
else -> chapterTitle
|
||||||
}
|
}
|
||||||
if (chapterTitle != previous?.getChapterTitle(resources) && chapterTitle.isNotEmpty()) {
|
if (
|
||||||
|
settings.isReaderChapterToastEnabled &&
|
||||||
|
chapterTitle != previous?.getChapterTitle(resources) &&
|
||||||
|
chapterTitle.isNotEmpty()
|
||||||
|
) {
|
||||||
viewBinding.toastView.showTemporary(chapterTitle, TOAST_DURATION)
|
viewBinding.toastView.showTemporary(chapterTitle, TOAST_DURATION)
|
||||||
}
|
}
|
||||||
if (uiState.isSliderAvailable()) {
|
if (uiState.isSliderAvailable()) {
|
||||||
|
|||||||
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.isVisible
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.transition.TransitionManager
|
||||||
import com.google.android.material.button.MaterialButtonToggleGroup
|
import com.google.android.material.button.MaterialButtonToggleGroup
|
||||||
|
import com.google.android.material.slider.Slider
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
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.consume
|
||||||
import org.koitharu.kotatsu.core.util.ext.findParentCallback
|
import org.koitharu.kotatsu.core.util.ext.findParentCallback
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
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.ext.viewLifecycleScope
|
||||||
|
import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter
|
||||||
import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding
|
import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding
|
||||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||||
@@ -37,7 +41,8 @@ class ReaderConfigSheet :
|
|||||||
BaseAdaptiveSheet<SheetReaderConfigBinding>(),
|
BaseAdaptiveSheet<SheetReaderConfigBinding>(),
|
||||||
View.OnClickListener,
|
View.OnClickListener,
|
||||||
MaterialButtonToggleGroup.OnButtonCheckedListener,
|
MaterialButtonToggleGroup.OnButtonCheckedListener,
|
||||||
CompoundButton.OnCheckedChangeListener {
|
CompoundButton.OnCheckedChangeListener,
|
||||||
|
Slider.OnChangeListener {
|
||||||
|
|
||||||
private val viewModel by activityViewModels<ReaderViewModel>()
|
private val viewModel by activityViewModels<ReaderViewModel>()
|
||||||
|
|
||||||
@@ -86,8 +91,9 @@ class ReaderConfigSheet :
|
|||||||
binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL
|
binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL
|
||||||
binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape
|
binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape
|
||||||
binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED
|
binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED
|
||||||
binding.switchPullGesture.isChecked = settings.isWebtoonPullGestureEnabled
|
binding.sliderDoubleSensitivity.setValueRounded(settings.readerDoublePagesSensitivity * 100f)
|
||||||
binding.switchPullGesture.isEnabled = mode == ReaderMode.WEBTOON
|
binding.sliderDoubleSensitivity.setLabelFormatter(IntPercentLabelFormatter(binding.root.context))
|
||||||
|
binding.adjustSensitivitySlider(withAnimation = false)
|
||||||
|
|
||||||
binding.checkableGroup.addOnButtonCheckedListener(this)
|
binding.checkableGroup.addOnButtonCheckedListener(this)
|
||||||
binding.buttonSavePage.setOnClickListener(this)
|
binding.buttonSavePage.setOnClickListener(this)
|
||||||
@@ -98,7 +104,7 @@ class ReaderConfigSheet :
|
|||||||
binding.buttonScrollTimer.setOnClickListener(this)
|
binding.buttonScrollTimer.setOnClickListener(this)
|
||||||
binding.buttonBookmark.setOnClickListener(this)
|
binding.buttonBookmark.setOnClickListener(this)
|
||||||
binding.switchDoubleReader.setOnCheckedChangeListener(this)
|
binding.switchDoubleReader.setOnCheckedChangeListener(this)
|
||||||
binding.switchPullGesture.setOnCheckedChangeListener(this)
|
binding.sliderDoubleSensitivity.addOnChangeListener(this)
|
||||||
|
|
||||||
viewModel.isBookmarkAdded.observe(viewLifecycleOwner) {
|
viewModel.isBookmarkAdded.observe(viewLifecycleOwner) {
|
||||||
binding.buttonBookmark.setText(if (it) R.string.bookmark_remove else R.string.bookmark_add)
|
binding.buttonBookmark.setText(if (it) R.string.bookmark_remove else R.string.bookmark_add)
|
||||||
@@ -173,15 +179,16 @@ class ReaderConfigSheet :
|
|||||||
|
|
||||||
R.id.switch_double_reader -> {
|
R.id.switch_double_reader -> {
|
||||||
settings.isReaderDoubleOnLandscape = isChecked
|
settings.isReaderDoubleOnLandscape = isChecked
|
||||||
|
viewBinding?.adjustSensitivitySlider(withAnimation = true)
|
||||||
findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked)
|
findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked)
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.switch_pull_gesture -> {
|
|
||||||
settings.isWebtoonPullGestureEnabled = isChecked
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
|
||||||
|
settings.readerDoublePagesSensitivity = value / 100f
|
||||||
|
}
|
||||||
|
|
||||||
override fun onButtonChecked(
|
override fun onButtonChecked(
|
||||||
group: MaterialButtonToggleGroup?,
|
group: MaterialButtonToggleGroup?,
|
||||||
checkedId: Int,
|
checkedId: Int,
|
||||||
@@ -197,8 +204,10 @@ class ReaderConfigSheet :
|
|||||||
R.id.button_vertical -> ReaderMode.VERTICAL
|
R.id.button_vertical -> ReaderMode.VERTICAL
|
||||||
else -> return
|
else -> return
|
||||||
}
|
}
|
||||||
viewBinding?.switchDoubleReader?.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED
|
viewBinding?.run {
|
||||||
viewBinding?.switchPullGesture?.isEnabled = newMode == ReaderMode.WEBTOON
|
switchDoubleReader.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED
|
||||||
|
adjustSensitivitySlider(withAnimation = true)
|
||||||
|
}
|
||||||
if (newMode == mode) {
|
if (newMode == mode) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -232,6 +241,15 @@ class ReaderConfigSheet :
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun SheetReaderConfigBinding.adjustSensitivitySlider(withAnimation: Boolean) {
|
||||||
|
val isSliderVisible = switchDoubleReader.isEnabled && switchDoubleReader.isChecked
|
||||||
|
if (isSliderVisible != sliderDoubleSensitivity.isVisible && withAnimation) {
|
||||||
|
TransitionManager.beginDelayedTransition(layoutMain)
|
||||||
|
}
|
||||||
|
sliderDoubleSensitivity.isVisible = isSliderVisible
|
||||||
|
textDoubleSensitivity.isVisible = isSliderVisible
|
||||||
|
}
|
||||||
|
|
||||||
interface Callback {
|
interface Callback {
|
||||||
|
|
||||||
fun onReaderModeChanged(mode: ReaderMode)
|
fun onReaderModeChanged(mode: ReaderMode)
|
||||||
|
|||||||
@@ -25,11 +25,26 @@ abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>(), ZoomCont
|
|||||||
readerAdapter = onCreateAdapter()
|
readerAdapter = onCreateAdapter()
|
||||||
|
|
||||||
viewModel.content.observe(viewLifecycleOwner) {
|
viewModel.content.observe(viewLifecycleOwner) {
|
||||||
if (it.state == null && it.pages.isNotEmpty() && readerAdapter?.hasItems != true) {
|
// Determine which state to use for restoring position:
|
||||||
onPagesChanged(it.pages, viewModel.getCurrentState())
|
// - content.state: explicitly set state (e.g., after mode switch or chapter change)
|
||||||
} else {
|
// - getCurrentState(): current reading position saved in SavedStateHandle
|
||||||
onPagesChanged(it.pages, it.state)
|
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
|
||||||
import androidx.recyclerview.widget.RecyclerView.SmoothScroller.ScrollVectorProvider
|
import androidx.recyclerview.widget.RecyclerView.SmoothScroller.ScrollVectorProvider
|
||||||
import androidx.recyclerview.widget.SnapHelper
|
import androidx.recyclerview.widget.SnapHelper
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.sign
|
||||||
|
|
||||||
class DoublePageSnapHelper : SnapHelper() {
|
class DoublePageSnapHelper(private val settings: AppSettings) : SnapHelper() {
|
||||||
|
|
||||||
private lateinit var recyclerView: RecyclerView
|
private lateinit var recyclerView: RecyclerView
|
||||||
|
|
||||||
@@ -248,28 +251,27 @@ class DoublePageSnapHelper : SnapHelper() {
|
|||||||
equal to zero.
|
equal to zero.
|
||||||
*/
|
*/
|
||||||
fun getPositionsToMove(llm: LinearLayoutManager, scroll: Int, itemSize: Int): Int {
|
fun getPositionsToMove(llm: LinearLayoutManager, scroll: Int, itemSize: Int): Int {
|
||||||
var positionsToMove: Int
|
val sensitivity = settings.readerDoublePagesSensitivity.coerceIn(0f, 1f) * 2.5
|
||||||
positionsToMove = roundUpToBlockSize(abs((scroll.toDouble()) / itemSize).roundToInt())
|
var positionsToMove = (scroll.toDouble() / (itemSize * (2.5 - sensitivity))).roundToInt()
|
||||||
if (positionsToMove < blockSize) {
|
|
||||||
// Must move at least one block
|
// Apply a maximum threshold
|
||||||
positionsToMove = blockSize
|
val maxPages = (4 * sensitivity).roundToInt().coerceAtLeast(1)
|
||||||
} else if (positionsToMove > maxPositionsToMove) {
|
if (positionsToMove.absoluteValue > maxPages) {
|
||||||
// Clamp number of positions to move, so we don't get wild flinging.
|
positionsToMove = maxPages * positionsToMove.sign
|
||||||
positionsToMove = maxPositionsToMove
|
|
||||||
}
|
}
|
||||||
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
|
val currentPosition = if (layoutDirectionHelper.isDirectionToBottom(scroll < 0)) {
|
||||||
}
|
llm.findFirstVisibleItemPosition()
|
||||||
return if (layoutDirectionHelper.isDirectionToBottom(scroll < 0)) {
|
|
||||||
// Scrolling toward the bottom of data.
|
|
||||||
roundDownToBlockSize(llm.findFirstVisibleItemPosition()) + positionsToMove
|
|
||||||
} else {
|
} 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 {
|
fun isDirectionToBottom(velocityNegative: Boolean): Boolean {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.os.NetworkState
|
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.ui.list.lifecycle.RecyclerViewLifecycleDispatcher
|
||||||
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
|
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
|
||||||
import org.koitharu.kotatsu.databinding.FragmentReaderDoubleBinding
|
import org.koitharu.kotatsu.databinding.FragmentReaderDoubleBinding
|
||||||
@@ -33,6 +34,9 @@ open class DoubleReaderFragment : BaseReaderFragment<FragmentReaderDoubleBinding
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var pageLoader: PageLoader
|
lateinit var pageLoader: PageLoader
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var settings: AppSettings
|
||||||
|
|
||||||
private var recyclerLifecycleDispatcher: RecyclerViewLifecycleDispatcher? = null
|
private var recyclerLifecycleDispatcher: RecyclerViewLifecycleDispatcher? = null
|
||||||
|
|
||||||
override fun onCreateViewBinding(
|
override fun onCreateViewBinding(
|
||||||
@@ -51,7 +55,7 @@ open class DoubleReaderFragment : BaseReaderFragment<FragmentReaderDoubleBinding
|
|||||||
addOnScrollListener(it)
|
addOnScrollListener(it)
|
||||||
}
|
}
|
||||||
addOnScrollListener(PageScrollListener())
|
addOnScrollListener(PageScrollListener())
|
||||||
DoublePageSnapHelper().attachToRecyclerView(this)
|
DoublePageSnapHelper(settings).attachToRecyclerView(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class WebtoonImageView @JvmOverloads constructor(
|
|||||||
fun scrollTo(y: Int) {
|
fun scrollTo(y: Int) {
|
||||||
val maxScroll = getScrollRange()
|
val maxScroll = getScrollRange()
|
||||||
if (maxScroll == 0) {
|
if (maxScroll == 0) {
|
||||||
resetScaleAndCenter()
|
scrollToInternal(0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
scrollToInternal(y.coerceIn(0, maxScroll))
|
scrollToInternal(y.coerceIn(0, maxScroll))
|
||||||
|
|||||||
@@ -28,13 +28,21 @@ class WebtoonRecyclerView @JvmOverloads constructor(
|
|||||||
private var isFixingScroll = false
|
private var isFixingScroll = false
|
||||||
|
|
||||||
var isPullGestureEnabled: Boolean = false
|
var isPullGestureEnabled: Boolean = false
|
||||||
|
set(value) {
|
||||||
|
if (field != value) {
|
||||||
|
field = value
|
||||||
|
setEdgeEffectFactory(
|
||||||
|
if (value) {
|
||||||
|
PullEffect.Factory()
|
||||||
|
} else {
|
||||||
|
EdgeEffectFactory()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
var pullThreshold: Float = 0.3f
|
var pullThreshold: Float = 0.3f
|
||||||
private var pullListener: OnPullGestureListener? = null
|
private var pullListener: OnPullGestureListener? = null
|
||||||
|
|
||||||
init {
|
|
||||||
setEdgeEffectFactory(PullEffect.Factory())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setOnPullGestureListener(listener: OnPullGestureListener?) {
|
fun setOnPullGestureListener(listener: OnPullGestureListener?) {
|
||||||
pullListener = listener
|
pullListener = listener
|
||||||
}
|
}
|
||||||
@@ -248,7 +256,7 @@ class WebtoonRecyclerView @JvmOverloads constructor(
|
|||||||
|
|
||||||
override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect {
|
override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect {
|
||||||
val pullListener = (view as? WebtoonRecyclerView)?.pullListener
|
val pullListener = (view as? WebtoonRecyclerView)?.pullListener
|
||||||
return if (pullListener != null && view.isPullGestureEnabled) {
|
return if (pullListener != null) {
|
||||||
PullEffect(view, direction, view.pullThreshold, pullListener)
|
PullEffect(view, direction, view.pullThreshold, pullListener)
|
||||||
} else {
|
} else {
|
||||||
super.createEdgeEffect(view, direction)
|
super.createEdgeEffect(view, direction)
|
||||||
|
|||||||
@@ -29,120 +29,133 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
|
|||||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@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
|
override val filterCoordinator: FilterCoordinator
|
||||||
get() = viewModel.filterCoordinator
|
get() = viewModel.filterCoordinator
|
||||||
|
|
||||||
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
|
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
|
||||||
super.onViewBindingCreated(binding, savedInstanceState)
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
addMenuProvider(RemoteListMenuProvider())
|
addMenuProvider(RemoteListMenuProvider())
|
||||||
addMenuProvider(MangaSearchMenuProvider(filterCoordinator, viewModel))
|
addMenuProvider(MangaSearchMenuProvider(filterCoordinator, viewModel))
|
||||||
viewModel.isRandomLoading.observe(viewLifecycleOwner, MenuInvalidator(requireActivity()))
|
viewModel.isRandomLoading.observe(viewLifecycleOwner, MenuInvalidator(requireActivity()))
|
||||||
viewModel.onOpenManga.observeEvent(viewLifecycleOwner) { router.openDetails(it) }
|
viewModel.onOpenManga.observeEvent(viewLifecycleOwner) { router.openDetails(it) }
|
||||||
filterCoordinator.observe().distinctUntilChangedBy { it.listFilter.isEmpty() }
|
viewModel.onSourceBroken.observeEvent(viewLifecycleOwner) { showSourceBrokenWarning() }
|
||||||
.drop(1)
|
filterCoordinator.observe().distinctUntilChangedBy { it.listFilter.isEmpty() }
|
||||||
.observe(viewLifecycleOwner) {
|
.drop(1)
|
||||||
activity?.invalidateMenu()
|
.observe(viewLifecycleOwner) {
|
||||||
}
|
activity?.invalidateMenu()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onScrolledToEnd() {
|
override fun onScrolledToEnd() {
|
||||||
viewModel.loadNextPage()
|
viewModel.loadNextPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateActionMode(
|
override fun onCreateActionMode(
|
||||||
controller: ListSelectionController,
|
controller: ListSelectionController,
|
||||||
menuInflater: MenuInflater,
|
menuInflater: MenuInflater,
|
||||||
menu: Menu
|
menu: Menu
|
||||||
): Boolean {
|
): Boolean {
|
||||||
menuInflater.inflate(R.menu.mode_remote, menu)
|
menuInflater.inflate(R.menu.mode_remote, menu)
|
||||||
return super.onCreateActionMode(controller, menuInflater, menu)
|
return super.onCreateActionMode(controller, menuInflater, menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFilterClick(view: View?) {
|
override fun onFilterClick(view: View?) {
|
||||||
router.showFilterSheet()
|
router.showFilterSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onEmptyActionClick() {
|
override fun onEmptyActionClick() {
|
||||||
if (filterCoordinator.isFilterApplied) {
|
if (filterCoordinator.isFilterApplied) {
|
||||||
filterCoordinator.reset()
|
filterCoordinator.reset()
|
||||||
} else {
|
} else {
|
||||||
openInBrowser(null) // should never be called
|
openInBrowser(null) // should never be called
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFooterButtonClick() {
|
override fun onFooterButtonClick() {
|
||||||
val filter = filterCoordinator.snapshot().listFilter
|
val filter = filterCoordinator.snapshot().listFilter
|
||||||
when {
|
when {
|
||||||
!filter.query.isNullOrEmpty() -> router.openSearch(filter.query.orEmpty(), SearchKind.SIMPLE)
|
!filter.query.isNullOrEmpty() -> router.openSearch(filter.query.orEmpty(), SearchKind.SIMPLE)
|
||||||
!filter.author.isNullOrEmpty() -> router.openSearch(filter.author.orEmpty(), SearchKind.AUTHOR)
|
!filter.author.isNullOrEmpty() -> router.openSearch(filter.author.orEmpty(), SearchKind.AUTHOR)
|
||||||
filter.tags.size == 1 -> router.openSearch(filter.tags.singleOrNull()?.title.orEmpty(), SearchKind.TAG)
|
filter.tags.size == 1 -> router.openSearch(filter.tags.singleOrNull()?.title.orEmpty(), SearchKind.TAG)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSecondaryErrorActionClick(error: Throwable) {
|
override fun onSecondaryErrorActionClick(error: Throwable) {
|
||||||
openInBrowser(error.getCauseUrl())
|
openInBrowser(error.getCauseUrl())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openInBrowser(url: String?) {
|
override fun onClick(v: View?) = Unit // from Snackbar, do nothing
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
private fun showSourceBrokenWarning() {
|
||||||
menuInflater.inflate(R.menu.opt_list_remote, menu)
|
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) {
|
private inner class RemoteListMenuProvider : MenuProvider {
|
||||||
R.id.action_source_settings -> {
|
|
||||||
router.openSourceSettings(viewModel.source)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_random -> {
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||||
viewModel.openRandom()
|
menuInflater.inflate(R.menu.opt_list_remote, menu)
|
||||||
true
|
}
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_filter -> {
|
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||||
onFilterClick(null)
|
R.id.action_source_settings -> {
|
||||||
true
|
router.openSourceSettings(viewModel.source)
|
||||||
}
|
true
|
||||||
|
}
|
||||||
|
|
||||||
R.id.action_filter_reset -> {
|
R.id.action_random -> {
|
||||||
filterCoordinator.reset()
|
viewModel.openRandom()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> false
|
R.id.action_filter -> {
|
||||||
}
|
onFilterClick(null)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
override fun onPrepareMenu(menu: Menu) {
|
R.id.action_filter_reset -> {
|
||||||
super.onPrepareMenu(menu)
|
filterCoordinator.reset()
|
||||||
menu.findItem(R.id.action_random)?.isEnabled = !viewModel.isRandomLoading.value
|
true
|
||||||
menu.findItem(R.id.action_filter_reset)?.isVisible = filterCoordinator.isFilterApplied
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
companion object {
|
||||||
putString(ARG_SOURCE, source.name)
|
|
||||||
}
|
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.data.LocalStorageChanges
|
||||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.util.sizeOrZero
|
import org.koitharu.kotatsu.parsers.util.sizeOrZero
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ open class RemoteListViewModel @Inject constructor(
|
|||||||
val source = MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE])
|
val source = MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE])
|
||||||
val isRandomLoading = MutableStateFlow(false)
|
val isRandomLoading = MutableStateFlow(false)
|
||||||
val onOpenManga = MutableEventFlow<Manga>()
|
val onOpenManga = MutableEventFlow<Manga>()
|
||||||
|
val onSourceBroken = MutableEventFlow<Unit>()
|
||||||
|
|
||||||
protected val repository = mangaRepositoryFactory.create(source)
|
protected val repository = mangaRepositoryFactory.create(source)
|
||||||
private val mangaList = MutableStateFlow<List<Manga>?>(null)
|
private val mangaList = MutableStateFlow<List<Manga>?>(null)
|
||||||
@@ -117,6 +119,11 @@ open class RemoteListViewModel @Inject constructor(
|
|||||||
launchJob(Dispatchers.Default) {
|
launchJob(Dispatchers.Default) {
|
||||||
sourcesRepository.trackUsage(source)
|
sourcesRepository.trackUsage(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (source is MangaParserSource && source.isBroken) {
|
||||||
|
// Just notify one. Will show reason in future
|
||||||
|
onSourceBroken.call(Unit)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRefresh() {
|
override fun onRefresh() {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package org.koitharu.kotatsu.scrobbling.common.data
|
package org.koitharu.kotatsu.scrobbling.common.data
|
||||||
|
|
||||||
import androidx.room.*
|
import androidx.room.*
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
abstract class ScrobblingDao {
|
abstract class ScrobblingDao {
|
||||||
@@ -20,4 +23,20 @@ abstract class ScrobblingDao {
|
|||||||
|
|
||||||
@Query("DELETE FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
|
@Query("DELETE FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
|
||||||
abstract suspend fun delete(scrobbler: Int, mangaId: Long)
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
@@ -47,7 +46,7 @@ class ScrobblerConfigViewModel @Inject constructor(
|
|||||||
val content = scrobbler.observeAllScrobblingInfo()
|
val content = scrobbler.observeAllScrobblingInfo()
|
||||||
.onStart { loadingCounter.increment() }
|
.onStart { loadingCounter.increment() }
|
||||||
.onFirst { loadingCounter.decrement() }
|
.onFirst { loadingCounter.decrement() }
|
||||||
.catch { errorEvent.call(it) }
|
.withErrorHandling()
|
||||||
.map { buildContentList(it) }
|
.map { buildContentList(it) }
|
||||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.scrobbling.discord.ui
|
package org.koitharu.kotatsu.scrobbling.discord.ui
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.SystemClock
|
||||||
import androidx.annotation.AnyThread
|
import androidx.annotation.AnyThread
|
||||||
import androidx.collection.ArrayMap
|
import androidx.collection.ArrayMap
|
||||||
import com.my.kizzyrpc.KizzyRPC
|
import com.my.kizzyrpc.KizzyRPC
|
||||||
@@ -14,6 +15,7 @@ import dagger.hilt.android.scopes.ViewModelScoped
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.plus
|
import kotlinx.coroutines.plus
|
||||||
import okio.utf8Size
|
import okio.utf8Size
|
||||||
@@ -35,6 +37,7 @@ import javax.inject.Inject
|
|||||||
private const val STATUS_ONLINE = "online"
|
private const val STATUS_ONLINE = "online"
|
||||||
private const val STATUS_IDLE = "idle"
|
private const val STATUS_IDLE = "idle"
|
||||||
private const val BUTTON_TEXT_LIMIT = 32
|
private const val BUTTON_TEXT_LIMIT = 32
|
||||||
|
private const val DEBOUNCE_TIMEOUT = 16_000L // 16 sec
|
||||||
|
|
||||||
@ViewModelScoped
|
@ViewModelScoped
|
||||||
class DiscordRpc @Inject constructor(
|
class DiscordRpc @Inject constructor(
|
||||||
@@ -49,6 +52,7 @@ class DiscordRpc @Inject constructor(
|
|||||||
private val appName = context.getString(R.string.app_name)
|
private val appName = context.getString(R.string.app_name)
|
||||||
private val appIcon = context.getString(R.string.app_icon_url)
|
private val appIcon = context.getString(R.string.app_icon_url)
|
||||||
private val mpCache = Collections.synchronizedMap(ArrayMap<String, String>())
|
private val mpCache = Collections.synchronizedMap(ArrayMap<String, String>())
|
||||||
|
private var lastUpdate = 0L
|
||||||
|
|
||||||
private var rpc: KizzyRPC? = null
|
private var rpc: KizzyRPC? = null
|
||||||
|
|
||||||
@@ -68,6 +72,7 @@ class DiscordRpc @Inject constructor(
|
|||||||
fun clearRpc() = synchronized(this) {
|
fun clearRpc() = synchronized(this) {
|
||||||
rpc?.closeRPC()
|
rpc?.closeRPC()
|
||||||
rpc = null
|
rpc = null
|
||||||
|
lastUpdate = 0L
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setIdle() {
|
fun setIdle() {
|
||||||
@@ -114,6 +119,10 @@ class DiscordRpc @Inject constructor(
|
|||||||
val prevJob = rpcUpdateJob
|
val prevJob = rpcUpdateJob
|
||||||
rpcUpdateJob = coroutineScope.launch {
|
rpcUpdateJob = coroutineScope.launch {
|
||||||
prevJob?.cancelAndJoin()
|
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 hideButtons = activity.buttons?.any { it != null && it.utf8Size() > BUTTON_TEXT_LIMIT } ?: false
|
||||||
val mappedActivity = activity.copy(
|
val mappedActivity = activity.copy(
|
||||||
assets = activity.assets?.let {
|
assets = activity.assets?.let {
|
||||||
@@ -131,6 +140,7 @@ class DiscordRpc @Inject constructor(
|
|||||||
status = if (idle) STATUS_IDLE else STATUS_ONLINE,
|
status = if (idle) STATUS_IDLE else STATUS_ONLINE,
|
||||||
since = activity.timestamps?.start ?: System.currentTimeMillis(),
|
since = activity.timestamps?.start ?: System.currentTimeMillis(),
|
||||||
)
|
)
|
||||||
|
lastUpdate = SystemClock.elapsedRealtime()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ class MALRepository @Inject constructor(
|
|||||||
storage.clear()
|
storage.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun jsonToManga(json: JSONObject, sourceTitle: String): ScrobblerManga? {
|
private fun jsonToManga(json: JSONObject, sourceTitle: String): ScrobblerManga {
|
||||||
val node = json.getJSONObject("node")
|
val node = json.getJSONObject("node")
|
||||||
val title = node.getString("title")
|
val title = node.getString("title")
|
||||||
return ScrobblerManga(
|
return ScrobblerManga(
|
||||||
|
|||||||
@@ -169,4 +169,8 @@ class MangaSearchRepository @Inject constructor(
|
|||||||
null,
|
null,
|
||||||
)?.use { cursor -> cursor.count } ?: 0
|
)?.use { cursor -> cursor.count } ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getAuthors(source: MangaSource, limit: Int): List<String> {
|
||||||
|
return db.getMangaDao().findAuthorsBySource(source.name, limit)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ fun searchResultsAD(
|
|||||||
binding.recyclerView.addItemDecoration(selectionDecoration)
|
binding.recyclerView.addItemDecoration(selectionDecoration)
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
val spacing = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
|
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)
|
val eventListener = AdapterDelegateClickListenerAdapter(this, itemClickListener)
|
||||||
binding.buttonMore.setOnClickListener(eventListener)
|
binding.buttonMore.setOnClickListener(eventListener)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package org.koitharu.kotatsu.search.ui.suggestion.adapter
|
package org.koitharu.kotatsu.search.ui.suggestion.adapter
|
||||||
|
|
||||||
import androidx.appcompat.widget.TooltipCompat
|
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
@@ -30,7 +29,7 @@ fun searchSuggestionMangaListAD(
|
|||||||
left = recyclerView.paddingLeft - spacing,
|
left = recyclerView.paddingLeft - spacing,
|
||||||
right = recyclerView.paddingRight - spacing,
|
right = recyclerView.paddingRight - spacing,
|
||||||
)
|
)
|
||||||
recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
|
recyclerView.addItemDecoration(SpacingItemDecoration(spacing, withBottomPadding = true))
|
||||||
val scrollResetCallback = RecyclerViewScrollCallback(recyclerView, 0, 0)
|
val scrollResetCallback = RecyclerViewScrollCallback(recyclerView, 0, 0)
|
||||||
|
|
||||||
bind {
|
bind {
|
||||||
|
|||||||
@@ -11,11 +11,16 @@ import androidx.appcompat.app.AppCompatDelegate
|
|||||||
import androidx.preference.ListPreference
|
import androidx.preference.ListPreference
|
||||||
import androidx.preference.MultiSelectListPreference
|
import androidx.preference.MultiSelectListPreference
|
||||||
import androidx.preference.Preference
|
import androidx.preference.Preference
|
||||||
|
import androidx.preference.TwoStatePreference
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||||
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode
|
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.BasePreferenceFragment
|
||||||
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
||||||
import org.koitharu.kotatsu.core.util.LocaleComparator
|
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.setDefaultValueCompat
|
||||||
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
|
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
|
||||||
import org.koitharu.kotatsu.core.util.ext.toList
|
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.names
|
||||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
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.ActivityListPreference
|
||||||
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
|
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
|
||||||
import org.koitharu.kotatsu.settings.utils.PercentSummaryProvider
|
import org.koitharu.kotatsu.settings.utils.PercentSummaryProvider
|
||||||
@@ -34,106 +41,145 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class AppearanceSettingsFragment :
|
class AppearanceSettingsFragment :
|
||||||
BasePreferenceFragment(R.string.appearance),
|
BasePreferenceFragment(R.string.appearance),
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var activityRecreationHandle: ActivityRecreationHandle
|
lateinit var activityRecreationHandle: ActivityRecreationHandle
|
||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
@Inject
|
||||||
addPreferencesFromResource(R.xml.pref_appearance)
|
lateinit var appShortcutManager: AppShortcutManager
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
addPreferencesFromResource(R.xml.pref_appearance)
|
||||||
settings.subscribe(this)
|
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() {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
settings.unsubscribe(this)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
super.onDestroyView()
|
settings.subscribe(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
|
override fun onDestroyView() {
|
||||||
when (key) {
|
settings.unsubscribe(this)
|
||||||
AppSettings.KEY_THEME -> {
|
super.onDestroyView()
|
||||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
AppSettings.KEY_COLOR_THEME,
|
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
|
||||||
AppSettings.KEY_THEME_AMOLED,
|
when (key) {
|
||||||
-> {
|
AppSettings.KEY_THEME -> {
|
||||||
postRestart()
|
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
AppSettings.KEY_APP_LOCALE -> {
|
AppSettings.KEY_COLOR_THEME,
|
||||||
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
AppSettings.KEY_THEME_AMOLED,
|
||||||
}
|
-> {
|
||||||
|
postRestart()
|
||||||
|
}
|
||||||
|
|
||||||
AppSettings.KEY_NAV_MAIN -> {
|
AppSettings.KEY_APP_LOCALE -> {
|
||||||
bindNavSummary()
|
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun postRestart() {
|
AppSettings.KEY_NAV_MAIN -> {
|
||||||
viewLifecycleOwner.lifecycle.postDelayed(400) {
|
bindNavSummary()
|
||||||
activityRecreationHandle.recreateAll()
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initLocalePicker(preference: ListPreference) {
|
AppSettings.KEY_APP_PASSWORD -> {
|
||||||
val locales = preference.context.getLocalesConfig()
|
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
|
||||||
.toList()
|
?.isChecked = !settings.appPassword.isNullOrEmpty()
|
||||||
.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() {
|
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||||
val pref = findPreference<Preference>(AppSettings.KEY_NAV_MAIN) ?: return
|
return when (preference.key) {
|
||||||
pref.summary = settings.mainNavItems.joinToString {
|
AppSettings.KEY_PROTECT_APP -> {
|
||||||
getString(it.title)
|
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)
|
addPreferencesFromResource(R.xml.pref_root_debug)
|
||||||
bindPreferenceSummary("appearance", R.string.theme, R.string.list_mode, R.string.language)
|
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("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("network", R.string.storage_usage, R.string.proxy, R.string.prefetch_content)
|
||||||
bindPreferenceSummary("userdata", R.string.protect_application, R.string.backup_restore, R.string.data_deletion)
|
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("downloads", R.string.manga_save_location, R.string.downloads_wifi_only)
|
||||||
bindPreferenceSummary("tracker", R.string.track_sources, R.string.notifications_settings)
|
bindPreferenceSummary("tracker", R.string.track_sources, R.string.notifications_settings)
|
||||||
bindPreferenceSummary("services", R.string.suggestions, R.string.sync, R.string.tracking)
|
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.SourcesSettingsFragment
|
||||||
import org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment
|
import org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment
|
||||||
import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment
|
import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment
|
||||||
import org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment
|
import org.koitharu.kotatsu.settings.userdata.BackupsSettingsFragment
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class SettingsActivity :
|
class SettingsActivity :
|
||||||
@@ -146,7 +146,7 @@ class SettingsActivity :
|
|||||||
val fragment = when (intent?.action) {
|
val fragment = when (intent?.action) {
|
||||||
AppRouter.ACTION_READER -> ReaderSettingsFragment()
|
AppRouter.ACTION_READER -> ReaderSettingsFragment()
|
||||||
AppRouter.ACTION_SUGGESTIONS -> SuggestionsSettingsFragment()
|
AppRouter.ACTION_SUGGESTIONS -> SuggestionsSettingsFragment()
|
||||||
AppRouter.ACTION_HISTORY -> UserDataSettingsFragment()
|
AppRouter.ACTION_HISTORY -> BackupsSettingsFragment()
|
||||||
AppRouter.ACTION_TRACKER -> TrackerSettingsFragment()
|
AppRouter.ACTION_TRACKER -> TrackerSettingsFragment()
|
||||||
AppRouter.ACTION_PERIODIC_BACKUP -> PeriodicalBackupSettingsFragment()
|
AppRouter.ACTION_PERIODIC_BACKUP -> PeriodicalBackupSettingsFragment()
|
||||||
AppRouter.ACTION_SOURCES -> SourcesSettingsFragment()
|
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 ->
|
viewModel.installIntent.value?.let { intent ->
|
||||||
try {
|
try {
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
} catch (e: ActivityNotFoundException) {
|
} catch (e: Exception) {
|
||||||
onError(e)
|
onError(e)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.core.net.toUri
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.isActive
|
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.call
|
||||||
import org.koitharu.kotatsu.core.util.ext.requireValue
|
import org.koitharu.kotatsu.core.util.ext.requireValue
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.coroutines.coroutineContext
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AppUpdateViewModel @Inject constructor(
|
class AppUpdateViewModel @Inject constructor(
|
||||||
@@ -79,7 +79,7 @@ class AppUpdateViewModel @Inject constructor(
|
|||||||
private suspend fun observeDownload(id: Long) {
|
private suspend fun observeDownload(id: Long) {
|
||||||
val query = DownloadManager.Query()
|
val query = DownloadManager.Query()
|
||||||
query.setFilterById(id)
|
query.setFilterById(id)
|
||||||
while (coroutineContext.isActive) {
|
while (currentCoroutineContext().isActive) {
|
||||||
downloadManager.query(query).use { cursor ->
|
downloadManager.query(query).use { cursor ->
|
||||||
if (cursor.moveToFirst()) {
|
if (cursor.moveToFirst()) {
|
||||||
val bytesDownloaded = cursor.getLong(
|
val bytesDownloaded = cursor.getLong(
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class DiscordSettingsFragment : BasePreferenceFragment(R.string.discord) {
|
|||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
addPreferencesFromResource(R.xml.pref_discord)
|
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(
|
pref.dialogMessage = pref.context.getString(
|
||||||
R.string.discord_token_description,
|
R.string.discord_token_description,
|
||||||
pref.context.getString(R.string.sign_in),
|
pref.context.getString(R.string.sign_in),
|
||||||
@@ -44,21 +44,21 @@ class DiscordSettingsFragment : BasePreferenceFragment(R.string.discord) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDisplayPreferenceDialog(preference: Preference) {
|
override fun onDisplayPreferenceDialog(preference: Preference) {
|
||||||
if (preference is EditTextPreference && preference.key == AppSettings.Companion.KEY_DISCORD_TOKEN) {
|
if (preference is EditTextPreference && preference.key == AppSettings.KEY_DISCORD_TOKEN) {
|
||||||
if (parentFragmentManager.findFragmentByTag(TokenDialogFragment.Companion.DIALOG_FRAGMENT_TAG) != null) {
|
if (parentFragmentManager.findFragmentByTag(TokenDialogFragment.DIALOG_FRAGMENT_TAG) != null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val f = TokenDialogFragment.newInstance(preference.key)
|
val f = TokenDialogFragment.newInstance(preference.key)
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
f.setTargetFragment(this, 0)
|
f.setTargetFragment(this, 0)
|
||||||
f.show(parentFragmentManager, TokenDialogFragment.Companion.DIALOG_FRAGMENT_TAG)
|
f.show(parentFragmentManager, TokenDialogFragment.DIALOG_FRAGMENT_TAG)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
super.onDisplayPreferenceDialog(preference)
|
super.onDisplayPreferenceDialog(preference)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bindTokenPreference(state: TokenState, token: String?) {
|
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) {
|
when (state) {
|
||||||
TokenState.EMPTY -> {
|
TokenState.EMPTY -> {
|
||||||
pref.icon = null
|
pref.icon = null
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class DiscordSettingsViewModel @Inject constructor(
|
|||||||
TokenState.CHECKING to settings.discordToken,
|
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
|
val token = settings.discordToken
|
||||||
if (!settings.isDiscordRpcEnabled) {
|
if (!settings.isDiscordRpcEnabled) {
|
||||||
emit(
|
emit(
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ data class SettingsItem(
|
|||||||
) : ListModel {
|
) : ListModel {
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
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.core.LocalizedAppContext
|
||||||
import org.koitharu.kotatsu.settings.AppearanceSettingsFragment
|
import org.koitharu.kotatsu.settings.AppearanceSettingsFragment
|
||||||
import org.koitharu.kotatsu.settings.DownloadsSettingsFragment
|
import org.koitharu.kotatsu.settings.DownloadsSettingsFragment
|
||||||
import org.koitharu.kotatsu.settings.NetworkSettingsFragment
|
|
||||||
import org.koitharu.kotatsu.settings.ProxySettingsFragment
|
import org.koitharu.kotatsu.settings.ProxySettingsFragment
|
||||||
import org.koitharu.kotatsu.settings.ReaderSettingsFragment
|
import org.koitharu.kotatsu.settings.ReaderSettingsFragment
|
||||||
import org.koitharu.kotatsu.settings.ServicesSettingsFragment
|
import org.koitharu.kotatsu.settings.ServicesSettingsFragment
|
||||||
|
import org.koitharu.kotatsu.settings.StorageAndNetworkSettingsFragment
|
||||||
import org.koitharu.kotatsu.settings.SuggestionsSettingsFragment
|
import org.koitharu.kotatsu.settings.SuggestionsSettingsFragment
|
||||||
import org.koitharu.kotatsu.settings.about.AboutSettingsFragment
|
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.sources.SourcesSettingsFragment
|
||||||
import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment
|
import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment
|
||||||
import org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment
|
import org.koitharu.kotatsu.settings.userdata.BackupsSettingsFragment
|
||||||
import org.koitharu.kotatsu.settings.userdata.storage.StorageManageSettingsFragment
|
import org.koitharu.kotatsu.settings.userdata.storage.DataCleanupSettingsFragment
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@Reusable
|
@Reusable
|
||||||
@SuppressLint("RestrictedApi")
|
@SuppressLint("RestrictedApi")
|
||||||
class SettingsSearchHelper @Inject constructor(
|
class SettingsSearchHelper @Inject constructor(
|
||||||
@LocalizedAppContext private val context: Context,
|
@LocalizedAppContext private val context: Context,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun inflatePreferences(): List<SettingsItem> {
|
fun inflatePreferences(): List<SettingsItem> {
|
||||||
val preferenceManager = PreferenceManager(context)
|
val preferenceManager = PreferenceManager(context)
|
||||||
val result = ArrayList<SettingsItem>()
|
val result = ArrayList<SettingsItem>()
|
||||||
preferenceManager.inflateTo(result, R.xml.pref_appearance, emptyList(), AppearanceSettingsFragment::class.java)
|
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_sources, emptyList(), SourcesSettingsFragment::class.java)
|
||||||
preferenceManager.inflateTo(result, R.xml.pref_reader, emptyList(), ReaderSettingsFragment::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(
|
||||||
preferenceManager.inflateTo(result, R.xml.pref_user_data, emptyList(), UserDataSettingsFragment::class.java)
|
result,
|
||||||
preferenceManager.inflateTo(
|
R.xml.pref_network_storage,
|
||||||
result,
|
emptyList(),
|
||||||
R.xml.pref_storage,
|
StorageAndNetworkSettingsFragment::class.java,
|
||||||
listOf(context.getString(R.string.data_and_privacy)),
|
)
|
||||||
StorageManageSettingsFragment::class.java,
|
preferenceManager.inflateTo(result, R.xml.pref_backups, emptyList(), BackupsSettingsFragment::class.java)
|
||||||
)
|
preferenceManager.inflateTo(
|
||||||
preferenceManager.inflateTo(result, R.xml.pref_downloads, emptyList(), DownloadsSettingsFragment::class.java)
|
result,
|
||||||
preferenceManager.inflateTo(result, R.xml.pref_tracker, emptyList(), TrackerSettingsFragment::class.java)
|
R.xml.pref_data_cleanup,
|
||||||
preferenceManager.inflateTo(result, R.xml.pref_services, emptyList(), ServicesSettingsFragment::class.java)
|
listOf(context.getString(R.string.storage_and_network)),
|
||||||
preferenceManager.inflateTo(result, R.xml.pref_about, emptyList(), AboutSettingsFragment::class.java)
|
DataCleanupSettingsFragment::class.java,
|
||||||
preferenceManager.inflateTo(
|
)
|
||||||
result,
|
preferenceManager.inflateTo(result, R.xml.pref_downloads, emptyList(), DownloadsSettingsFragment::class.java)
|
||||||
R.xml.pref_backup_periodic,
|
preferenceManager.inflateTo(result, R.xml.pref_tracker, emptyList(), TrackerSettingsFragment::class.java)
|
||||||
listOf(context.getString(R.string.data_and_privacy)),
|
preferenceManager.inflateTo(result, R.xml.pref_services, emptyList(), ServicesSettingsFragment::class.java)
|
||||||
PeriodicalBackupSettingsFragment::class.java,
|
preferenceManager.inflateTo(result, R.xml.pref_about, emptyList(), AboutSettingsFragment::class.java)
|
||||||
)
|
preferenceManager.inflateTo(
|
||||||
preferenceManager.inflateTo(
|
result,
|
||||||
result,
|
R.xml.pref_backup_periodic,
|
||||||
R.xml.pref_proxy,
|
listOf(context.getString(R.string.backup_restore)),
|
||||||
listOf(context.getString(R.string.proxy)),
|
PeriodicalBackupSettingsFragment::class.java,
|
||||||
ProxySettingsFragment::class.java,
|
)
|
||||||
)
|
preferenceManager.inflateTo(
|
||||||
preferenceManager.inflateTo(
|
result,
|
||||||
result,
|
R.xml.pref_proxy,
|
||||||
R.xml.pref_suggestions,
|
listOf(context.getString(R.string.storage_and_network)),
|
||||||
listOf(context.getString(R.string.suggestions)),
|
ProxySettingsFragment::class.java,
|
||||||
SuggestionsSettingsFragment::class.java,
|
)
|
||||||
)
|
preferenceManager.inflateTo(
|
||||||
preferenceManager.inflateTo(
|
result,
|
||||||
result,
|
R.xml.pref_suggestions,
|
||||||
R.xml.pref_sources,
|
listOf(context.getString(R.string.services)),
|
||||||
listOf(context.getString(R.string.remote_sources)),
|
SuggestionsSettingsFragment::class.java,
|
||||||
SourcesSettingsFragment::class.java,
|
)
|
||||||
)
|
preferenceManager.inflateTo(
|
||||||
return result
|
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(
|
private fun PreferenceManager.inflateTo(
|
||||||
result: MutableList<SettingsItem>,
|
result: MutableList<SettingsItem>,
|
||||||
@XmlRes resId: Int,
|
@XmlRes resId: Int,
|
||||||
breadcrumbs: List<String>,
|
breadcrumbs: List<String>,
|
||||||
fragmentClass: Class<out PreferenceFragmentCompat>
|
fragmentClass: Class<out PreferenceFragmentCompat>
|
||||||
) {
|
) {
|
||||||
val screen = inflateFromResource(context, resId, null)
|
val screen = inflateFromResource(context, resId, null)
|
||||||
val screenTitle = screen.title?.toString()
|
val screenTitle = screen.title?.toString()
|
||||||
screen.inflateTo(
|
screen.inflateTo(
|
||||||
result = result,
|
result = result,
|
||||||
breadcrumbs = if (screenTitle.isNullOrEmpty()) breadcrumbs else breadcrumbs + screenTitle,
|
breadcrumbs = if (screenTitle.isNullOrEmpty()) breadcrumbs else breadcrumbs + screenTitle,
|
||||||
fragmentClass = fragmentClass,
|
fragmentClass = fragmentClass,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun PreferenceScreen.inflateTo(
|
private fun PreferenceScreen.inflateTo(
|
||||||
result: MutableList<SettingsItem>,
|
result: MutableList<SettingsItem>,
|
||||||
breadcrumbs: List<String>,
|
breadcrumbs: List<String>,
|
||||||
fragmentClass: Class<out PreferenceFragmentCompat>
|
fragmentClass: Class<out PreferenceFragmentCompat>
|
||||||
): Unit = repeat(preferenceCount) { i ->
|
): Unit = repeat(preferenceCount) { i ->
|
||||||
val pref = this[i]
|
val pref = this[i]
|
||||||
if (pref is PreferenceScreen) {
|
if (pref is PreferenceScreen) {
|
||||||
val screenTitle = pref.title?.toString()
|
val screenTitle = pref.title?.toString()
|
||||||
pref.inflateTo(
|
pref.inflateTo(
|
||||||
result = result,
|
result = result,
|
||||||
breadcrumbs = if (screenTitle.isNullOrEmpty()) breadcrumbs else breadcrumbs + screenTitle,
|
breadcrumbs = if (screenTitle.isNullOrEmpty()) breadcrumbs else breadcrumbs + screenTitle,
|
||||||
fragmentClass = fragmentClass,
|
fragmentClass = fragmentClass,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
result.add(
|
result.add(
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
key = pref.key ?: return@repeat,
|
key = pref.key ?: return@repeat,
|
||||||
title = pref.title ?: return@repeat,
|
title = pref.title ?: return@repeat,
|
||||||
breadcrumbs = breadcrumbs,
|
breadcrumbs = breadcrumbs,
|
||||||
fragmentClass = fragmentClass,
|
fragmentClass = fragmentClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.nav.router
|
import org.koitharu.kotatsu.core.nav.router
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
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.ui.BasePreferenceFragment
|
||||||
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
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()
|
entries = SourcesSortOrder.entries.map { context.getString(it.titleResId) }.toTypedArray()
|
||||||
setDefaultValueCompat(SourcesSortOrder.MANUAL.name)
|
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?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
|||||||
@@ -1,38 +1,66 @@
|
|||||||
package org.koitharu.kotatsu.settings.storage.directories
|
package org.koitharu.kotatsu.settings.storage.directories
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
import androidx.core.content.ContextCompat
|
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 com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
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.setTooltipCompat
|
||||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||||
import org.koitharu.kotatsu.databinding.ItemStorageConfigBinding
|
import org.koitharu.kotatsu.databinding.ItemStorageConfig2Binding
|
||||||
import org.koitharu.kotatsu.settings.storage.DirectoryModel
|
|
||||||
|
|
||||||
fun directoryConfigAD(
|
fun directoryConfigAD(
|
||||||
clickListener: OnListItemClickListener<DirectoryModel>,
|
clickListener: OnListItemClickListener<DirectoryConfigModel>,
|
||||||
) = adapterDelegateViewBinding<DirectoryModel, DirectoryModel, ItemStorageConfigBinding>(
|
) = adapterDelegateViewBinding<DirectoryConfigModel, DirectoryConfigModel, ItemStorageConfig2Binding>(
|
||||||
{ layoutInflater, parent -> ItemStorageConfigBinding.inflate(layoutInflater, parent, false) },
|
{ layoutInflater, parent -> ItemStorageConfig2Binding.inflate(layoutInflater, parent, false) },
|
||||||
) {
|
) {
|
||||||
|
|
||||||
binding.buttonRemove.setOnClickListener { v -> clickListener.onItemClick(item, v) }
|
binding.buttonRemove.setOnClickListener { v -> clickListener.onItemClick(item, v) }
|
||||||
binding.buttonRemove.setTooltipCompat(binding.buttonRemove.contentDescription)
|
binding.buttonRemove.setTooltipCompat(binding.buttonRemove.contentDescription)
|
||||||
|
|
||||||
bind {
|
bind {
|
||||||
binding.textViewTitle.text = item.title ?: getString(item.titleRes)
|
binding.textViewTitle.text = item.title
|
||||||
binding.textViewSubtitle.textAndVisible = item.file?.absolutePath
|
binding.textViewSubtitle.text = item.path.absolutePath
|
||||||
binding.buttonRemove.isVisible = item.isRemovable
|
binding.buttonRemove.isGone = item.isAppPrivate
|
||||||
binding.buttonRemove.isEnabled = !item.isChecked
|
binding.buttonRemove.isEnabled = !item.isDefault
|
||||||
binding.textViewTitle.drawableStart = if (!item.isAvailable) {
|
binding.spacer.visibility = if (item.isAppPrivate) {
|
||||||
ContextCompat.getDrawable(context, R.drawable.ic_alert_outline)?.apply {
|
View.INVISIBLE
|
||||||
setTint(ContextCompat.getColor(context, R.color.warning))
|
} else {
|
||||||
}
|
View.GONE
|
||||||
} else if (item.isChecked) {
|
}
|
||||||
ContextCompat.getDrawable(context, R.drawable.ic_download)
|
binding.textViewInfo.textAndVisible = buildSpannedString {
|
||||||
} else {
|
if (item.isDefault) {
|
||||||
null
|
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.os.OpenDocumentTreeHelper
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
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.consumeAllSystemBarsInsets
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
||||||
import org.koitharu.kotatsu.databinding.ActivityMangaDirectoriesBinding
|
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
|
import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MangaDirectoriesActivity : BaseActivity<ActivityMangaDirectoriesBinding>(),
|
class MangaDirectoriesActivity : BaseActivity<ActivityMangaDirectoriesBinding>(),
|
||||||
OnListItemClickListener<DirectoryModel>, View.OnClickListener {
|
OnListItemClickListener<DirectoryConfigModel>, View.OnClickListener {
|
||||||
|
|
||||||
private val viewModel: MangaDirectoriesViewModel by viewModels()
|
private val viewModel: MangaDirectoriesViewModel by viewModels()
|
||||||
private val pickFileTreeLauncher = OpenDocumentTreeHelper(
|
private val pickFileTreeLauncher = OpenDocumentTreeHelper(
|
||||||
@@ -63,8 +62,10 @@ class MangaDirectoriesActivity : BaseActivity<ActivityMangaDirectoriesBinding>()
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(ActivityMangaDirectoriesBinding.inflate(layoutInflater))
|
setContentView(ActivityMangaDirectoriesBinding.inflate(layoutInflater))
|
||||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
|
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
|
||||||
val adapter = AsyncListDifferDelegationAdapter(DirectoryDiffCallback(), directoryConfigAD(this))
|
val adapter = AsyncListDifferDelegationAdapter(DirectoryConfigDiffCallback(), directoryConfigAD(this))
|
||||||
viewBinding.recyclerView.adapter = adapter
|
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing_large)
|
||||||
|
viewBinding.recyclerView.adapter = adapter
|
||||||
|
viewBinding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing, withBottomPadding = false))
|
||||||
viewBinding.fabAdd.setOnClickListener(this)
|
viewBinding.fabAdd.setOnClickListener(this)
|
||||||
viewModel.items.observe(this) { adapter.items = it }
|
viewModel.items.observe(this) { adapter.items = it }
|
||||||
viewModel.isLoading.observe(this) { viewBinding.progressBar.isVisible = it }
|
viewModel.isLoading.observe(this) { viewBinding.progressBar.isVisible = it }
|
||||||
@@ -76,8 +77,8 @@ class MangaDirectoriesActivity : BaseActivity<ActivityMangaDirectoriesBinding>()
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemClick(item: DirectoryModel, view: View) {
|
override fun onItemClick(item: DirectoryConfigModel, view: View) {
|
||||||
viewModel.onRemoveClick(item.file ?: return)
|
viewModel.onRemoveClick(item.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClick(v: View?) {
|
override fun onClick(v: View?) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.settings.storage.directories
|
package org.koitharu.kotatsu.settings.storage.directories
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.StatFs
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -8,82 +9,87 @@ import kotlinx.coroutines.cancelAndJoin
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.computeSize
|
||||||
import org.koitharu.kotatsu.core.util.ext.isReadable
|
import org.koitharu.kotatsu.core.util.ext.isReadable
|
||||||
import org.koitharu.kotatsu.core.util.ext.isWriteable
|
import org.koitharu.kotatsu.core.util.ext.isWriteable
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||||
import org.koitharu.kotatsu.settings.storage.DirectoryModel
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class MangaDirectoriesViewModel @Inject constructor(
|
class MangaDirectoriesViewModel @Inject constructor(
|
||||||
private val storageManager: LocalStorageManager,
|
private val storageManager: LocalStorageManager,
|
||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
val items = MutableStateFlow(emptyList<DirectoryModel>())
|
val items = MutableStateFlow(emptyList<DirectoryConfigModel>())
|
||||||
private var loadingJob: Job? = null
|
private var loadingJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadList()
|
loadList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateList() {
|
fun updateList() {
|
||||||
loadList()
|
loadList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onCustomDirectoryPicked(uri: Uri) {
|
fun onCustomDirectoryPicked(uri: Uri) {
|
||||||
launchLoadingJob(Dispatchers.Default) {
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
loadingJob?.cancelAndJoin()
|
loadingJob?.cancelAndJoin()
|
||||||
storageManager.takePermissions(uri)
|
storageManager.takePermissions(uri)
|
||||||
val dir = storageManager.resolveUri(uri)
|
val dir = storageManager.resolveUri(uri)
|
||||||
if (!dir.canRead()) {
|
if (!dir.canRead()) {
|
||||||
throw AccessDeniedException(dir)
|
throw AccessDeniedException(dir)
|
||||||
}
|
}
|
||||||
if (dir !in storageManager.getApplicationStorageDirs()) {
|
if (dir !in storageManager.getApplicationStorageDirs()) {
|
||||||
settings.userSpecifiedMangaDirectories += dir
|
settings.userSpecifiedMangaDirectories += dir
|
||||||
loadList()
|
loadList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onRemoveClick(directory: File) {
|
fun onRemoveClick(directory: File) {
|
||||||
settings.userSpecifiedMangaDirectories -= directory
|
settings.userSpecifiedMangaDirectories -= directory
|
||||||
if (settings.mangaStorageDir == directory) {
|
if (settings.mangaStorageDir == directory) {
|
||||||
settings.mangaStorageDir = null
|
settings.mangaStorageDir = null
|
||||||
}
|
}
|
||||||
loadList()
|
loadList()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadList() {
|
private fun loadList() {
|
||||||
val prevJob = loadingJob
|
val prevJob = loadingJob
|
||||||
loadingJob = launchJob(Dispatchers.Default) {
|
loadingJob = launchJob(Dispatchers.Default) {
|
||||||
prevJob?.cancelAndJoin()
|
prevJob?.cancelAndJoin()
|
||||||
val downloadDir = storageManager.getDefaultWriteableDir()
|
val downloadDir = storageManager.getDefaultWriteableDir()
|
||||||
val applicationDirs = storageManager.getApplicationStorageDirs()
|
val applicationDirs = storageManager.getApplicationStorageDirs()
|
||||||
val customDirs = settings.userSpecifiedMangaDirectories - applicationDirs
|
val customDirs = settings.userSpecifiedMangaDirectories - applicationDirs
|
||||||
items.value = buildList(applicationDirs.size + customDirs.size) {
|
items.value = buildList(applicationDirs.size + customDirs.size) {
|
||||||
applicationDirs.mapTo(this) { dir ->
|
applicationDirs.mapTo(this) { dir ->
|
||||||
DirectoryModel(
|
dir.toDirectoryModel(
|
||||||
title = storageManager.getDirectoryDisplayName(dir, isFullPath = false),
|
isDefault = dir == downloadDir,
|
||||||
titleRes = 0,
|
isAppPrivate = true,
|
||||||
file = dir,
|
)
|
||||||
isChecked = dir == downloadDir,
|
}
|
||||||
isAvailable = dir.isReadable() && dir.isWriteable(),
|
customDirs.mapTo(this) { dir ->
|
||||||
isRemovable = false,
|
dir.toDirectoryModel(
|
||||||
)
|
isDefault = dir == downloadDir,
|
||||||
}
|
isAppPrivate = false,
|
||||||
customDirs.mapTo(this) { dir ->
|
)
|
||||||
DirectoryModel(
|
}
|
||||||
title = storageManager.getDirectoryDisplayName(dir, isFullPath = false),
|
}
|
||||||
titleRes = 0,
|
}
|
||||||
file = dir,
|
}
|
||||||
isChecked = dir == downloadDir,
|
|
||||||
isAvailable = dir.isReadable() && dir.isWriteable(),
|
private suspend fun File.toDirectoryModel(
|
||||||
isRemovable = true,
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package org.koitharu.kotatsu.settings.userdata
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import androidx.activity.result.ActivityResultCallback
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.preference.Preference
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.backups.domain.BackupUtils
|
||||||
|
import org.koitharu.kotatsu.backups.ui.backup.BackupService
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
|
import org.koitharu.kotatsu.core.nav.router
|
||||||
|
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.tryLaunch
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class BackupsSettingsFragment : BasePreferenceFragment(R.string.backup_restore),
|
||||||
|
ActivityResultCallback<Uri?> {
|
||||||
|
|
||||||
|
private val viewModel: BackupsSettingsViewModel by viewModels()
|
||||||
|
|
||||||
|
private val backupSelectCall = registerForActivityResult(
|
||||||
|
ActivityResultContracts.OpenDocument(),
|
||||||
|
this,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val backupCreateCall = registerForActivityResult(
|
||||||
|
ActivityResultContracts.CreateDocument("application/zip"),
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
if (!BackupService.start(requireContext(), uri)) {
|
||||||
|
Snackbar.make(
|
||||||
|
listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
|
addPreferencesFromResource(R.xml.pref_backups)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
bindPeriodicalBackupSummary()
|
||||||
|
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||||
|
return when (preference.key) {
|
||||||
|
AppSettings.KEY_BACKUP -> {
|
||||||
|
if (!backupCreateCall.tryLaunch(BackupUtils.generateFileName(preference.context))) {
|
||||||
|
Snackbar.make(
|
||||||
|
listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
AppSettings.KEY_RESTORE -> {
|
||||||
|
if (!backupSelectCall.tryLaunch(arrayOf("*/*"))) {
|
||||||
|
Snackbar.make(
|
||||||
|
listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> super.onPreferenceTreeClick(preference)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(result: Uri?) {
|
||||||
|
if (result != null) {
|
||||||
|
router.showBackupRestoreDialog(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindPeriodicalBackupSummary() {
|
||||||
|
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_ENABLED) ?: return
|
||||||
|
val entries = resources.getStringArray(R.array.backup_frequency)
|
||||||
|
val entryValues = resources.getStringArray(R.array.values_backup_frequency)
|
||||||
|
viewModel.periodicalBackupFrequency.observe(viewLifecycleOwner) { freq ->
|
||||||
|
preference.summary = if (freq == 0L) {
|
||||||
|
getString(R.string.disabled)
|
||||||
|
} else {
|
||||||
|
val index = entryValues.indexOf(freq.toString())
|
||||||
|
entries.getOrNull(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package org.koitharu.kotatsu.settings.userdata
|
||||||
|
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class BackupsSettingsViewModel @Inject constructor(
|
||||||
|
private val settings: AppSettings,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val periodicalBackupFrequency = settings.observeAsFlow(
|
||||||
|
key = AppSettings.KEY_BACKUP_PERIODICAL_ENABLED,
|
||||||
|
valueProducer = { isPeriodicalBackupEnabled },
|
||||||
|
).flatMapLatest { isEnabled ->
|
||||||
|
if (isEnabled) {
|
||||||
|
settings.observeAsFlow(
|
||||||
|
key = AppSettings.KEY_BACKUP_PERIODICAL_FREQUENCY,
|
||||||
|
valueProducer = { periodicalBackupFrequency },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
flowOf(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.settings.userdata
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.View
|
|
||||||
import androidx.activity.result.ActivityResultCallback
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.fragment.app.viewModels
|
|
||||||
import androidx.preference.ListPreference
|
|
||||||
import androidx.preference.MultiSelectListPreference
|
|
||||||
import androidx.preference.Preference
|
|
||||||
import androidx.preference.TwoStatePreference
|
|
||||||
import com.google.android.material.snackbar.Snackbar
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.backups.domain.BackupUtils
|
|
||||||
import org.koitharu.kotatsu.backups.ui.backup.BackupService
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
|
||||||
import org.koitharu.kotatsu.core.nav.router
|
|
||||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
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.util.FileSize
|
|
||||||
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.core.util.ext.tryLaunch
|
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
|
||||||
import org.koitharu.kotatsu.parsers.util.names
|
|
||||||
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
|
|
||||||
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privacy),
|
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener,
|
|
||||||
ActivityResultCallback<Uri?> {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var appShortcutManager: AppShortcutManager
|
|
||||||
|
|
||||||
private val viewModel: UserDataSettingsViewModel by viewModels()
|
|
||||||
|
|
||||||
private val backupSelectCall = registerForActivityResult(
|
|
||||||
ActivityResultContracts.OpenDocument(),
|
|
||||||
this,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val backupCreateCall = registerForActivityResult(
|
|
||||||
ActivityResultContracts.CreateDocument("application/zip"),
|
|
||||||
) { uri ->
|
|
||||||
if (uri != null) {
|
|
||||||
if (!BackupService.start(requireContext(), uri)) {
|
|
||||||
Snackbar.make(
|
|
||||||
listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
|
||||||
addPreferencesFromResource(R.xml.pref_user_data)
|
|
||||||
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<ListPreference>(AppSettings.KEY_INCOGNITO_NSFW)?.run {
|
|
||||||
entryValues = TriStateOption.entries.names()
|
|
||||||
setDefaultValueCompat(TriStateOption.ASK.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
super.onViewCreated(view, savedInstanceState)
|
|
||||||
bindPeriodicalBackupSummary()
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
findPreference<Preference>(AppSettings.KEY_STORAGE_USAGE)?.let { pref ->
|
|
||||||
viewModel.storageUsage.observe(viewLifecycleOwner) { size ->
|
|
||||||
pref.summary = if (size < 0L) {
|
|
||||||
pref.context.getString(R.string.computing_)
|
|
||||||
} else {
|
|
||||||
FileSize.BYTES.format(pref.context, size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
|
|
||||||
settings.subscribe(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
|
||||||
settings.unsubscribe(this)
|
|
||||||
super.onDestroyView()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
|
||||||
return when (preference.key) {
|
|
||||||
AppSettings.KEY_BACKUP -> {
|
|
||||||
if (!backupCreateCall.tryLaunch(BackupUtils.generateFileName(preference.context))) {
|
|
||||||
Snackbar.make(
|
|
||||||
listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
AppSettings.KEY_RESTORE -> {
|
|
||||||
if (!backupSelectCall.tryLaunch(arrayOf("*/*"))) {
|
|
||||||
Snackbar.make(
|
|
||||||
listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
AppSettings.KEY_PROTECT_APP -> {
|
|
||||||
val pref = (preference as? TwoStatePreference ?: return false)
|
|
||||||
if (pref.isChecked) {
|
|
||||||
pref.isChecked = false
|
|
||||||
startActivity(Intent(preference.context, ProtectSetupActivity::class.java))
|
|
||||||
} else {
|
|
||||||
settings.appPassword = null
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> super.onPreferenceTreeClick(preference)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
|
||||||
when (key) {
|
|
||||||
AppSettings.KEY_APP_PASSWORD -> {
|
|
||||||
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
|
|
||||||
?.isChecked = !settings.appPassword.isNullOrEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivityResult(result: Uri?) {
|
|
||||||
if (result != null) {
|
|
||||||
router.showBackupRestoreDialog(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindPeriodicalBackupSummary() {
|
|
||||||
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_ENABLED) ?: return
|
|
||||||
val entries = resources.getStringArray(R.array.backup_frequency)
|
|
||||||
val entryValues = resources.getStringArray(R.array.values_backup_frequency)
|
|
||||||
viewModel.periodicalBackupFrequency.observe(viewLifecycleOwner) { freq ->
|
|
||||||
preference.summary = if (freq == 0L) {
|
|
||||||
getString(R.string.disabled)
|
|
||||||
} else {
|
|
||||||
val index = entryValues.indexOf(freq.toString())
|
|
||||||
entries.getOrNull(index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.settings.userdata
|
|
||||||
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class UserDataSettingsViewModel @Inject constructor(
|
|
||||||
private val storageManager: LocalStorageManager,
|
|
||||||
private val settings: AppSettings,
|
|
||||||
) : BaseViewModel() {
|
|
||||||
|
|
||||||
val storageUsage = MutableStateFlow(-1L)
|
|
||||||
|
|
||||||
val periodicalBackupFrequency = settings.observeAsFlow(
|
|
||||||
key = AppSettings.KEY_BACKUP_PERIODICAL_ENABLED,
|
|
||||||
valueProducer = { isPeriodicalBackupEnabled },
|
|
||||||
).flatMapLatest { isEnabled ->
|
|
||||||
if (isEnabled) {
|
|
||||||
settings.observeAsFlow(
|
|
||||||
key = AppSettings.KEY_BACKUP_PERIODICAL_FREQUENCY,
|
|
||||||
valueProducer = { periodicalBackupFrequency },
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
flowOf(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var storageUsageJob: Job? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
loadStorageUsage()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadStorageUsage(): Job {
|
|
||||||
val prevJob = storageUsageJob
|
|
||||||
return launchJob(Dispatchers.Default) {
|
|
||||||
prevJob?.cancelAndJoin()
|
|
||||||
val totalBytes = storageManager.computeCacheSize() + storageManager.computeStorageSize()
|
|
||||||
storageUsage.value = totalBytes
|
|
||||||
}.also {
|
|
||||||
storageUsageJob = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package org.koitharu.kotatsu.settings.userdata.storage
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.preference.Preference
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||||
|
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||||
|
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||||
|
import org.koitharu.kotatsu.core.util.FileSize
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
|
import org.koitharu.kotatsu.local.data.CacheDir
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class DataCleanupSettingsFragment : BasePreferenceFragment(R.string.data_removal) {
|
||||||
|
|
||||||
|
private val viewModel by viewModels<DataCleanupSettingsViewModel>()
|
||||||
|
private val loadingPrefs = HashSet<String>()
|
||||||
|
|
||||||
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
|
addPreferencesFromResource(R.xml.pref_data_cleanup)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES]))
|
||||||
|
findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS]))
|
||||||
|
findPreference<Preference>(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize)
|
||||||
|
findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref ->
|
||||||
|
viewModel.searchHistoryCount.observe(viewLifecycleOwner) {
|
||||||
|
pref.summary = if (it < 0) {
|
||||||
|
view.context.getString(R.string.loading_)
|
||||||
|
} else {
|
||||||
|
pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
findPreference<Preference>(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref ->
|
||||||
|
viewModel.feedItemsCount.observe(viewLifecycleOwner) {
|
||||||
|
pref.summary = if (it < 0) {
|
||||||
|
view.context.getString(R.string.loading_)
|
||||||
|
} else {
|
||||||
|
pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
findPreference<Preference>(AppSettings.KEY_WEBVIEW_CLEAR)?.isVisible = viewModel.isBrowserDataCleanupEnabled
|
||||||
|
|
||||||
|
viewModel.loadingKeys.observe(viewLifecycleOwner) { keys ->
|
||||||
|
loadingPrefs.addAll(keys)
|
||||||
|
loadingPrefs.forEach { prefKey ->
|
||||||
|
findPreference<Preference>(prefKey)?.isEnabled = prefKey !in keys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
|
||||||
|
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView))
|
||||||
|
viewModel.onChaptersCleanedUp.observeEvent(viewLifecycleOwner, ::onChaptersCleanedUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) {
|
||||||
|
AppSettings.KEY_COOKIES_CLEAR -> {
|
||||||
|
clearCookies()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
AppSettings.KEY_SEARCH_HISTORY_CLEAR -> {
|
||||||
|
clearSearchHistory()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
AppSettings.KEY_PAGES_CACHE_CLEAR -> {
|
||||||
|
viewModel.clearCache(preference.key, CacheDir.PAGES)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
AppSettings.KEY_THUMBS_CACHE_CLEAR -> {
|
||||||
|
viewModel.clearCache(preference.key, CacheDir.THUMBS, CacheDir.FAVICONS)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
AppSettings.KEY_HTTP_CACHE_CLEAR -> {
|
||||||
|
viewModel.clearHttpCache()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
AppSettings.KEY_CHAPTERS_CLEAR -> {
|
||||||
|
cleanupChapters()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
AppSettings.KEY_WEBVIEW_CLEAR -> {
|
||||||
|
viewModel.clearBrowserData()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
AppSettings.KEY_CLEAR_MANGA_DATA -> {
|
||||||
|
viewModel.clearMangaData()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
AppSettings.KEY_UPDATES_FEED_CLEAR -> {
|
||||||
|
viewModel.clearUpdatesFeed()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> super.onPreferenceTreeClick(preference)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onChaptersCleanedUp(result: Pair<Int, Long>) {
|
||||||
|
val c = context ?: return
|
||||||
|
val text = if (result.first == 0 && result.second == 0L) {
|
||||||
|
c.getString(R.string.no_chapters_deleted)
|
||||||
|
} else {
|
||||||
|
c.getString(
|
||||||
|
R.string.chapters_deleted_pattern,
|
||||||
|
c.resources.getQuantityStringSafe(R.plurals.chapters, result.first, result.first),
|
||||||
|
FileSize.BYTES.format(c, result.second),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Snackbar.make(listView, text, Snackbar.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Preference.bindBytesSizeSummary(stateFlow: StateFlow<Long>) {
|
||||||
|
stateFlow.observe(viewLifecycleOwner) { size ->
|
||||||
|
summary = if (size < 0) {
|
||||||
|
context.getString(R.string.computing_)
|
||||||
|
} else {
|
||||||
|
FileSize.BYTES.format(context, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearSearchHistory() {
|
||||||
|
buildAlertDialog(context ?: return) {
|
||||||
|
setTitle(R.string.clear_search_history)
|
||||||
|
setMessage(R.string.text_clear_search_history_prompt)
|
||||||
|
setNegativeButton(android.R.string.cancel, null)
|
||||||
|
setPositiveButton(R.string.clear) { _, _ ->
|
||||||
|
viewModel.clearSearchHistory()
|
||||||
|
}
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearCookies() {
|
||||||
|
buildAlertDialog(context ?: return) {
|
||||||
|
setTitle(R.string.clear_cookies)
|
||||||
|
setMessage(R.string.text_clear_cookies_prompt)
|
||||||
|
setNegativeButton(android.R.string.cancel, null)
|
||||||
|
setPositiveButton(R.string.clear) { _, _ ->
|
||||||
|
viewModel.clearCookies()
|
||||||
|
}
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanupChapters() {
|
||||||
|
buildAlertDialog(context ?: return) {
|
||||||
|
setTitle(R.string.delete_read_chapters)
|
||||||
|
setMessage(R.string.delete_read_chapters_prompt)
|
||||||
|
setNegativeButton(android.R.string.cancel, null)
|
||||||
|
setPositiveButton(R.string.delete) { _, _ ->
|
||||||
|
viewModel.cleanupChapters()
|
||||||
|
}
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user