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