Compare commits

...

49 Commits
v9.3 ... v9.4.1

Author SHA1 Message Date
Koitharu
06a0b5829b Fix crashes
(cherry picked from commit 1d32f53bdd)
2025-11-03 20:27:53 +02:00
Koitharu
0ce2870c8b Fix chapters list not accessible
(cherry picked from commit 5701862661)
2025-11-03 20:27:24 +02:00
Koitharu
f59027666b Fix loading empty manga
(cherry picked from commit 5590ab7c8a)
2025-11-03 20:27:18 +02:00
Nathan Bapin
8513bc6daf Fix forget page when the screen is rotated (#1674)
(cherry picked from commit e2fcfcc7a8)
2025-11-03 20:27:10 +02:00
Koitharu
cceaefc896 Avoid memory leak in ExceptionResolver
(cherry picked from commit 7a3b2a9bb4)
2025-11-03 20:27:05 +02:00
Koitharu
881f154b5e Update parsers 2025-11-02 09:47:46 +02:00
Koitharu
34be5d16f2 Merge pull request #1701 from weblate/weblate-kotatsu-strings 2025-11-02 09:22:10 +02:00
Milo Ivir
e7e554648d Translated using Weblate (Croatian)
Currently translated at 100.0% (893 of 893 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
2025-10-30 04:25:59 +00:00
Draken
89a4180b46 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (893 of 893 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-10-30 04:25:55 +00:00
MuhamadSyabitHidayattulloh
4e2e190547 Translated using Weblate (Indonesian)
Currently translated at 100.0% (893 of 893 strings)

Co-authored-by: MuhamadSyabitHidayattulloh <tebepc@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-10-30 04:25:53 +00:00
João Augusto Casagrande
3c557aae6c Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (893 of 893 strings)

Co-authored-by: João Augusto Casagrande <joao.augusto1809@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2025-10-30 04:25:50 +00:00
Nicola Bortoletto
0b00a3675d Translated using Weblate (Italian)
Currently translated at 100.0% (893 of 893 strings)

Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-10-30 04:25:41 +00:00
Alvoracz
8f20be6953 Translated using Weblate (Czech)
Currently translated at 97.8% (874 of 893 strings)

Co-authored-by: Alvoracz <sedlor@seznam.cz>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translation: Kotatsu/Strings
2025-10-30 04:25:35 +00:00
Kanta Sekiguchi
26875c01c6 Translated using Weblate (Japanese)
Currently translated at 90.8% (811 of 893 strings)

Co-authored-by: Kanta Sekiguchi <kanta.sekiguchi360@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2025-10-30 04:25:32 +00:00
Koitharu
4beb34c1a5 Translated using Weblate (Russian)
Currently translated at 99.7% (891 of 893 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
Conrado
1d50ab00c4 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (889 of 889 strings)

Co-authored-by: Conrado <deadlocked53.89@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
Ruffghanor
299cd229ec Translated using Weblate (Portuguese)
Currently translated at 100.0% (889 of 889 strings)

Co-authored-by: Ruffghanor <ruffghanor20@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
Nahid hasan Limon
b02f394cd4 Translated using Weblate (Bengali)
Currently translated at 22.9% (204 of 889 strings)

Co-authored-by: Nahid hasan Limon <nahidhasanlimon401@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/bn/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
Oğuz Ersen
7352f06564 Translated using Weblate (Turkish)
Currently translated at 100.0% (893 of 893 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (889 of 889 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (882 of 882 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
Milo Ivir
1e4861367e Translated using Weblate (Croatian)
Currently translated at 100.0% (882 of 882 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
Draken
bc3208946b Translated using Weblate (Vietnamese)
Currently translated at 100.0% (889 of 889 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (880 of 880 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
MuhamadSyabitHidayattulloh
d5fbb00676 Translated using Weblate (Indonesian)
Currently translated at 100.0% (889 of 889 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (880 of 880 strings)

Co-authored-by: MuhamadSyabitHidayattulloh <tebepc@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/id/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2025-10-26 17:51:58 +02:00
Hecker_01
7514362ca4 Translated using Weblate (Dutch)
Currently translated at 4.0% (36 of 880 strings)

Translated using Weblate (Dutch)

Currently translated at 88.8% (8 of 9 strings)

Added translation using Weblate (Dutch)

Added translation using Weblate (Dutch)

Co-authored-by: Hecker_01 <jesseflantua@icloud.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/nl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nl/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2025-10-26 17:51:58 +02:00
Infy's Tagalog Translations
e76a04bea0 Translated using Weblate (Filipino)
Currently translated at 98.9% (871 of 880 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
Максим Горпиніч
732a6e7c26 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (889 of 889 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (882 of 882 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (880 of 880 strings)

Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
Макар Разин
f3111dc636 Translated using Weblate (Russian)
Currently translated at 100.0% (876 of 876 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
Nicola Bortoletto
e0e0cf4ecd Translated using Weblate (Italian)
Currently translated at 100.0% (889 of 889 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (882 of 882 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (876 of 876 strings)

Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
Nataniel Dika Kurniawan
50f302a7f8 Translated using Weblate (Indonesian)
Currently translated at 100.0% (876 of 876 strings)

Co-authored-by: Nataniel Dika Kurniawan <hikawaart2@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-10-26 17:51:58 +02:00
google-labs-jules[bot]
500995a9d8 feat(settings): Add "Every 6 hours" option for periodic backups
Adds a new "Every 6 hours" frequency option to the periodic backup settings.

To maintain consistency with the existing preference values, which are stored in days, this new option is represented internally as a fractional value of `0.25` days.

The implementation includes:
- Adding the new string resource and updating the preference arrays.
- Changing the preference type in `AppSettings.kt` from `Long` to `Float` to accommodate the fractional value.
- Updating the millisecond conversion logic to correctly calculate the interval from a float value in days.

This approach avoids a complex data migration and is simpler and safer than changing the base unit for all values from days to hours.
2025-10-26 17:36:51 +02:00
Koitharu
beaf5cc0d5 Remove SavedFilterBackup class 2025-10-26 17:35:29 +02:00
google-labs-jules[bot]
6377de470d feat: Add saved filters to backup and restore
This commit adds support for backing up and restoring saved filters.

- Added a new `SAVED_FILTERS` section to the backup process.
- Implemented the logic to read filters from SharedPreferences during backup and write them back during restore.
- Fixed compilation errors in `AppBackupAgent` and `BackupSectionModel`.
2025-10-26 17:30:49 +02:00
google-labs-jules[bot]
dec45f7851 feat: Add saved filters to backup and restore
This commit adds support for backing up and restoring saved filters.

- Added a new `SAVED_FILTERS` section to the backup process.
- Implemented the logic to read filters from SharedPreferences during backup and write them back during restore.
2025-10-26 17:30:49 +02:00
Koitharu
dbada34a43 Move pull gesture option to reader settings 2025-10-26 17:27:33 +02:00
Koitharu
b62467964e Fix filters on tablet 2025-10-26 17:03:37 +02:00
Koitharu
3249e10931 Exclude broken sources from catalog 2025-10-26 16:47:54 +02:00
Koitharu
0d5229b112 Improve local manga directories config screen 2025-10-26 16:33:59 +02:00
Koitharu
d0ed1fb85f Notify about broken source on list screen 2025-10-21 18:04:05 +03:00
Koitharu
9e5664da3a Reorganize settings 2025-10-21 17:51:11 +03:00
Koitharu
35c158d35a Update readme 2025-10-21 15:51:12 +03:00
Koitharu
464f24e9f0 Fix unwanted touch events when chapters sheet is collapsed 2025-10-21 15:46:34 +03:00
Koitharu
c8a8203c39 Add authors to filter 2025-10-21 14:45:10 +03:00
Koitharu
b414758f32 Improve saved filters 2025-10-21 12:22:30 +03:00
Koitharu
1181860e41 Improve saved filters 2025-10-20 14:18:08 +03:00
Koitharu
e35521f16f Fix code formatting 2025-10-20 09:08:04 +03:00
MuhamadSyabitHidayattulloh
5fb8ff53f9 Feat: Add Saved Filters Feature 2025-10-20 09:07:56 +03:00
Vicente
a66283d035 Backup Restore reading stats 2025-10-20 08:45:40 +03:00
Vicente
a1ba0b8c21 Backup scrobblings 2025-10-20 08:45:40 +03:00
Koitharu
f3b42b9a42 Small improvement for chapter toast setting 2025-10-20 08:42:02 +03:00
google-labs-jules[bot]
aa2f2c17fc feat(reader): Add setting to toggle chapter toast 2025-10-20 08:37:18 +03:00
110 changed files with 7437 additions and 5601 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -21,8 +21,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 23
targetSdk = 36
versionCode = 1031
versionName = '9.3'
versionCode = 1033
versionName = '9.4.1'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {

View File

@@ -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)
}
}
}
}

View File

@@ -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,
)
}

View File

@@ -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,
)
}

View File

@@ -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
@@ -39,9 +41,17 @@ class AppBackupAgent : BackupAgent() {
val file = createBackupFile(
this,
BackupRepository(
MangaDatabase(context = applicationContext),
AppSettings(applicationContext),
TapGridSettings(applicationContext),
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 {
@@ -67,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()

View File

@@ -15,6 +15,9 @@ enum class BackupSection(
SETTINGS_READER_GRID("reader_grid"),
BOOKMARKS("bookmarks"),
SOURCES("sources"),
SCROBBLING("scrobbling"),
STATS("statistics"),
SAVED_FILTERS("saved_filters"),
;
companion object {

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -409,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)
@@ -543,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)) {
@@ -678,7 +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"
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"
@@ -747,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"

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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,
)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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> {

View File

@@ -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),
)
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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")
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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,
)
}

View File

@@ -1,23 +1,39 @@
package org.koitharu.kotatsu.filter.ui.sheet
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
@@ -26,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
@@ -35,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()
}
}

View File

@@ -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

View File

@@ -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()
}
}
}

View File

@@ -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()) {

View File

@@ -10,6 +10,7 @@ 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
@@ -90,13 +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.textSensitivity.isVisible = settings.isReaderDoubleOnLandscape
binding.seekbarSensitivity.isVisible = settings.isReaderDoubleOnLandscape
binding.seekbarSensitivity.setValueRounded(settings.readerDoublePagesSensitivity * 100f)
binding.seekbarSensitivity.setLabelFormatter(IntPercentLabelFormatter(binding.root.context))
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)
@@ -107,8 +104,7 @@ class ReaderConfigSheet :
binding.buttonScrollTimer.setOnClickListener(this)
binding.buttonBookmark.setOnClickListener(this)
binding.switchDoubleReader.setOnCheckedChangeListener(this)
binding.switchPullGesture.setOnCheckedChangeListener(this)
binding.seekbarSensitivity.addOnChangeListener(this)
binding.sliderDoubleSensitivity.addOnChangeListener(this)
viewModel.isBookmarkAdded.observe(viewLifecycleOwner) {
binding.buttonBookmark.setText(if (it) R.string.bookmark_remove else R.string.bookmark_add)
@@ -183,14 +179,9 @@ class ReaderConfigSheet :
R.id.switch_double_reader -> {
settings.isReaderDoubleOnLandscape = isChecked
viewBinding?.textSensitivity?.isVisible = isChecked
viewBinding?.seekbarSensitivity?.isVisible = isChecked
viewBinding?.adjustSensitivitySlider(withAnimation = true)
findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked)
}
R.id.switch_pull_gesture -> {
settings.isWebtoonPullGestureEnabled = isChecked
}
}
}
@@ -213,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
}
@@ -248,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)

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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() {

View File

@@ -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) }
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -29,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 {

View File

@@ -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)
}
}
}

View File

@@ -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"
}
}
}
}

View File

@@ -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)

View File

@@ -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()

View File

@@ -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"
}
}
}
}

View File

@@ -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(),
),
)
}
}

View File

@@ -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
}
}

View File

@@ -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,
),
)
}
}
}

View File

@@ -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?) {

View File

@@ -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),
)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

@@ -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?) {

View File

@@ -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,
)
}

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,173 @@
package org.koitharu.kotatsu.settings.userdata.storage
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.local.data.CacheDir
@AndroidEntryPoint
class DataCleanupSettingsFragment : BasePreferenceFragment(R.string.data_removal) {
private val viewModel by viewModels<DataCleanupSettingsViewModel>()
private val loadingPrefs = HashSet<String>()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_data_cleanup)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES]))
findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS]))
findPreference<Preference>(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize)
findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref ->
viewModel.searchHistoryCount.observe(viewLifecycleOwner) {
pref.summary = if (it < 0) {
view.context.getString(R.string.loading_)
} else {
pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it)
}
}
}
findPreference<Preference>(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref ->
viewModel.feedItemsCount.observe(viewLifecycleOwner) {
pref.summary = if (it < 0) {
view.context.getString(R.string.loading_)
} else {
pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it)
}
}
}
findPreference<Preference>(AppSettings.KEY_WEBVIEW_CLEAR)?.isVisible = viewModel.isBrowserDataCleanupEnabled
viewModel.loadingKeys.observe(viewLifecycleOwner) { keys ->
loadingPrefs.addAll(keys)
loadingPrefs.forEach { prefKey ->
findPreference<Preference>(prefKey)?.isEnabled = prefKey !in keys
}
}
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView))
viewModel.onChaptersCleanedUp.observeEvent(viewLifecycleOwner, ::onChaptersCleanedUp)
}
override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) {
AppSettings.KEY_COOKIES_CLEAR -> {
clearCookies()
true
}
AppSettings.KEY_SEARCH_HISTORY_CLEAR -> {
clearSearchHistory()
true
}
AppSettings.KEY_PAGES_CACHE_CLEAR -> {
viewModel.clearCache(preference.key, CacheDir.PAGES)
true
}
AppSettings.KEY_THUMBS_CACHE_CLEAR -> {
viewModel.clearCache(preference.key, CacheDir.THUMBS, CacheDir.FAVICONS)
true
}
AppSettings.KEY_HTTP_CACHE_CLEAR -> {
viewModel.clearHttpCache()
true
}
AppSettings.KEY_CHAPTERS_CLEAR -> {
cleanupChapters()
true
}
AppSettings.KEY_WEBVIEW_CLEAR -> {
viewModel.clearBrowserData()
true
}
AppSettings.KEY_CLEAR_MANGA_DATA -> {
viewModel.clearMangaData()
true
}
AppSettings.KEY_UPDATES_FEED_CLEAR -> {
viewModel.clearUpdatesFeed()
true
}
else -> super.onPreferenceTreeClick(preference)
}
private fun onChaptersCleanedUp(result: Pair<Int, Long>) {
val c = context ?: return
val text = if (result.first == 0 && result.second == 0L) {
c.getString(R.string.no_chapters_deleted)
} else {
c.getString(
R.string.chapters_deleted_pattern,
c.resources.getQuantityStringSafe(R.plurals.chapters, result.first, result.first),
FileSize.BYTES.format(c, result.second),
)
}
Snackbar.make(listView, text, Snackbar.LENGTH_SHORT).show()
}
private fun Preference.bindBytesSizeSummary(stateFlow: StateFlow<Long>) {
stateFlow.observe(viewLifecycleOwner) { size ->
summary = if (size < 0) {
context.getString(R.string.computing_)
} else {
FileSize.BYTES.format(context, size)
}
}
}
private fun clearSearchHistory() {
buildAlertDialog(context ?: return) {
setTitle(R.string.clear_search_history)
setMessage(R.string.text_clear_search_history_prompt)
setNegativeButton(android.R.string.cancel, null)
setPositiveButton(R.string.clear) { _, _ ->
viewModel.clearSearchHistory()
}
}.show()
}
private fun clearCookies() {
buildAlertDialog(context ?: return) {
setTitle(R.string.clear_cookies)
setMessage(R.string.text_clear_cookies_prompt)
setNegativeButton(android.R.string.cancel, null)
setPositiveButton(R.string.clear) { _, _ ->
viewModel.clearCookies()
}
}.show()
}
private fun cleanupChapters() {
buildAlertDialog(context ?: return) {
setTitle(R.string.delete_read_chapters)
setMessage(R.string.delete_read_chapters_prompt)
setNegativeButton(android.R.string.cancel, null)
setPositiveButton(R.string.delete) { _, _ ->
viewModel.cleanupChapters()
}
}.show()
}
}

View File

@@ -0,0 +1,184 @@
package org.koitharu.kotatsu.settings.userdata.storage
import android.annotation.SuppressLint
import android.webkit.WebStorage
import androidx.webkit.WebStorageCompat
import androidx.webkit.WebViewFeature
import coil3.ImageLoader
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runInterruptible
import okhttp3.Cache
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.domain.DeleteReadChaptersUseCase
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import java.util.EnumMap
import javax.inject.Inject
import javax.inject.Provider
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@HiltViewModel
class DataCleanupSettingsViewModel @Inject constructor(
private val storageManager: LocalStorageManager,
private val httpCache: Cache,
private val searchRepository: MangaSearchRepository,
private val trackingRepository: TrackingRepository,
private val cookieJar: MutableCookieJar,
private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase,
private val mangaDataRepositoryProvider: Provider<MangaDataRepository>,
private val coil: ImageLoader,
) : BaseViewModel() {
val onActionDone = MutableEventFlow<ReversibleAction>()
val loadingKeys = MutableStateFlow(emptySet<String>())
val searchHistoryCount = MutableStateFlow(-1)
val feedItemsCount = MutableStateFlow(-1)
val httpCacheSize = MutableStateFlow(-1L)
val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java)
val onChaptersCleanedUp = MutableEventFlow<Pair<Int, Long>>()
val isBrowserDataCleanupEnabled: Boolean
get() = WebViewFeature.isFeatureSupported(WebViewFeature.DELETE_BROWSING_DATA)
init {
CacheDir.entries.forEach {
cacheSizes[it] = MutableStateFlow(-1L)
}
launchJob(Dispatchers.Default) {
searchHistoryCount.value = searchRepository.getSearchHistoryCount()
}
launchJob(Dispatchers.Default) {
feedItemsCount.value = trackingRepository.getLogsCount()
}
CacheDir.entries.forEach { cache ->
launchJob(Dispatchers.Default) {
checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache)
}
}
launchJob(Dispatchers.Default) {
httpCacheSize.value = runInterruptible { httpCache.size() }
}
}
fun clearCache(key: String, vararg caches: CacheDir) {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + key }
for (cache in caches) {
storageManager.clearCache(cache)
checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache)
if (cache == CacheDir.THUMBS) {
coil.memoryCache?.clear()
}
}
} finally {
loadingKeys.update { it - key }
}
}
}
fun clearHttpCache() {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + AppSettings.KEY_HTTP_CACHE_CLEAR }
val size = runInterruptible(Dispatchers.IO) {
httpCache.evictAll()
httpCache.size()
}
httpCacheSize.value = size
} finally {
loadingKeys.update { it - AppSettings.KEY_HTTP_CACHE_CLEAR }
}
}
}
fun clearSearchHistory() {
launchJob(Dispatchers.Default) {
searchRepository.clearSearchHistory()
searchHistoryCount.value = searchRepository.getSearchHistoryCount()
onActionDone.call(ReversibleAction(R.string.search_history_cleared, null))
}
}
fun clearCookies() {
launchJob {
cookieJar.clear()
onActionDone.call(ReversibleAction(R.string.cookies_cleared, null))
}
}
@SuppressLint("RequiresFeature")
fun clearBrowserData() {
launchJob {
try {
loadingKeys.update { it + AppSettings.KEY_WEBVIEW_CLEAR }
val storage = WebStorage.getInstance()
suspendCoroutine { cont ->
WebStorageCompat.deleteBrowsingData(storage) {
cont.resume(Unit)
}
}
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
} finally {
loadingKeys.update { it - AppSettings.KEY_WEBVIEW_CLEAR }
}
}
}
fun clearUpdatesFeed() {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + AppSettings.KEY_UPDATES_FEED_CLEAR }
trackingRepository.clearLogs()
feedItemsCount.value = trackingRepository.getLogsCount()
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
} finally {
loadingKeys.update { it - AppSettings.KEY_UPDATES_FEED_CLEAR }
}
}
}
fun clearMangaData() {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + AppSettings.KEY_CLEAR_MANGA_DATA }
trackingRepository.gc()
val repository = mangaDataRepositoryProvider.get()
repository.cleanupLocalManga()
repository.cleanupDatabase()
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
} finally {
loadingKeys.update { it - AppSettings.KEY_CLEAR_MANGA_DATA }
}
}
}
fun cleanupChapters() {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + AppSettings.KEY_CHAPTERS_CLEAR }
val oldSize = storageManager.computeStorageSize()
val chaptersCount = deleteReadChaptersUseCase.invoke()
val newSize = storageManager.computeStorageSize()
onChaptersCleanedUp.call(chaptersCount to oldSize - newSize)
} finally {
loadingKeys.update { it - AppSettings.KEY_CHAPTERS_CLEAR }
}
}
}
}

View File

@@ -1,173 +0,0 @@
package org.koitharu.kotatsu.settings.userdata.storage
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import androidx.preference.Preference
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.local.data.CacheDir
@AndroidEntryPoint
class StorageManageSettingsFragment : BasePreferenceFragment(R.string.storage_usage) {
private val viewModel by viewModels<StorageManageSettingsViewModel>()
private val loadingPrefs = HashSet<String>()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_storage)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES]))
findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS]))
findPreference<Preference>(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize)
findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref ->
viewModel.searchHistoryCount.observe(viewLifecycleOwner) {
pref.summary = if (it < 0) {
view.context.getString(R.string.loading_)
} else {
pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it)
}
}
}
findPreference<Preference>(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref ->
viewModel.feedItemsCount.observe(viewLifecycleOwner) {
pref.summary = if (it < 0) {
view.context.getString(R.string.loading_)
} else {
pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it)
}
}
}
findPreference<StorageUsagePreference>(AppSettings.KEY_STORAGE_USAGE)?.let { pref ->
viewModel.storageUsage.observe(viewLifecycleOwner, pref)
}
findPreference<Preference>(AppSettings.KEY_WEBVIEW_CLEAR)?.isVisible = viewModel.isBrowserDataCleanupEnabled
viewModel.loadingKeys.observe(viewLifecycleOwner) { keys ->
loadingPrefs.addAll(keys)
loadingPrefs.forEach { prefKey ->
findPreference<Preference>(prefKey)?.isEnabled = prefKey !in keys
}
}
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView))
viewModel.onChaptersCleanedUp.observeEvent(viewLifecycleOwner, ::onChaptersCleanedUp)
}
override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) {
AppSettings.KEY_COOKIES_CLEAR -> {
clearCookies()
true
}
AppSettings.KEY_SEARCH_HISTORY_CLEAR -> {
clearSearchHistory()
true
}
AppSettings.KEY_PAGES_CACHE_CLEAR -> {
viewModel.clearCache(preference.key, CacheDir.PAGES)
true
}
AppSettings.KEY_THUMBS_CACHE_CLEAR -> {
viewModel.clearCache(preference.key, CacheDir.THUMBS, CacheDir.FAVICONS)
true
}
AppSettings.KEY_HTTP_CACHE_CLEAR -> {
viewModel.clearHttpCache()
true
}
AppSettings.KEY_CHAPTERS_CLEAR -> {
cleanupChapters()
true
}
AppSettings.KEY_WEBVIEW_CLEAR -> {
viewModel.clearBrowserData()
true
}
AppSettings.KEY_CLEAR_MANGA_DATA -> {
viewModel.clearMangaData()
true
}
AppSettings.KEY_UPDATES_FEED_CLEAR -> {
viewModel.clearUpdatesFeed()
true
}
else -> super.onPreferenceTreeClick(preference)
}
private fun onChaptersCleanedUp(result: Pair<Int, Long>) {
val c = context ?: return
val text = if (result.first == 0 && result.second == 0L) {
c.getString(R.string.no_chapters_deleted)
} else {
c.getString(
R.string.chapters_deleted_pattern,
c.resources.getQuantityStringSafe(R.plurals.chapters, result.first, result.first),
FileSize.BYTES.format(c, result.second),
)
}
Snackbar.make(listView, text, Snackbar.LENGTH_SHORT).show()
}
private fun Preference.bindBytesSizeSummary(stateFlow: StateFlow<Long>) {
stateFlow.observe(viewLifecycleOwner) { size ->
summary = if (size < 0) {
context.getString(R.string.computing_)
} else {
FileSize.BYTES.format(context, size)
}
}
}
private fun clearSearchHistory() {
MaterialAlertDialogBuilder(context ?: return)
.setTitle(R.string.clear_search_history)
.setMessage(R.string.text_clear_search_history_prompt)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
viewModel.clearSearchHistory()
}.show()
}
private fun clearCookies() {
MaterialAlertDialogBuilder(context ?: return)
.setTitle(R.string.clear_cookies)
.setMessage(R.string.text_clear_cookies_prompt)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
viewModel.clearCookies()
}.show()
}
private fun cleanupChapters() {
MaterialAlertDialogBuilder(context ?: return)
.setTitle(R.string.delete_read_chapters)
.setMessage(R.string.delete_read_chapters_prompt)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.delete) { _, _ ->
viewModel.cleanupChapters()
}.show()
}
}

View File

@@ -1,226 +0,0 @@
package org.koitharu.kotatsu.settings.userdata.storage
import android.annotation.SuppressLint
import android.webkit.WebStorage
import androidx.webkit.WebStorageCompat
import androidx.webkit.WebViewFeature
import coil3.ImageLoader
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.update
import kotlinx.coroutines.runInterruptible
import okhttp3.Cache
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.firstNotNull
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.domain.DeleteReadChaptersUseCase
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import java.util.EnumMap
import javax.inject.Inject
import javax.inject.Provider
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@HiltViewModel
class StorageManageSettingsViewModel @Inject constructor(
private val storageManager: LocalStorageManager,
private val httpCache: Cache,
private val searchRepository: MangaSearchRepository,
private val trackingRepository: TrackingRepository,
private val cookieJar: MutableCookieJar,
private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase,
private val mangaDataRepositoryProvider: Provider<MangaDataRepository>,
private val coil: ImageLoader,
) : BaseViewModel() {
val onActionDone = MutableEventFlow<ReversibleAction>()
val loadingKeys = MutableStateFlow(emptySet<String>())
val searchHistoryCount = MutableStateFlow(-1)
val feedItemsCount = MutableStateFlow(-1)
val httpCacheSize = MutableStateFlow(-1L)
val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java)
val storageUsage = MutableStateFlow<StorageUsage?>(null)
val onChaptersCleanedUp = MutableEventFlow<Pair<Int, Long>>()
val isBrowserDataCleanupEnabled: Boolean
get() = WebViewFeature.isFeatureSupported(WebViewFeature.DELETE_BROWSING_DATA)
private var storageUsageJob: Job? = null
init {
CacheDir.entries.forEach {
cacheSizes[it] = MutableStateFlow(-1L)
}
launchJob(Dispatchers.Default) {
searchHistoryCount.value = searchRepository.getSearchHistoryCount()
}
launchJob(Dispatchers.Default) {
feedItemsCount.value = trackingRepository.getLogsCount()
}
CacheDir.entries.forEach { cache ->
launchJob(Dispatchers.Default) {
checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache)
}
}
launchJob(Dispatchers.Default) {
httpCacheSize.value = runInterruptible { httpCache.size() }
}
loadStorageUsage()
}
fun clearCache(key: String, vararg caches: CacheDir) {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + key }
for (cache in caches) {
storageManager.clearCache(cache)
checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache)
if (cache == CacheDir.THUMBS) {
coil.memoryCache?.clear()
}
}
loadStorageUsage()
} finally {
loadingKeys.update { it - key }
}
}
}
fun clearHttpCache() {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + AppSettings.KEY_HTTP_CACHE_CLEAR }
val size = runInterruptible(Dispatchers.IO) {
httpCache.evictAll()
httpCache.size()
}
httpCacheSize.value = size
loadStorageUsage()
} finally {
loadingKeys.update { it - AppSettings.KEY_HTTP_CACHE_CLEAR }
}
}
}
fun clearSearchHistory() {
launchJob(Dispatchers.Default) {
searchRepository.clearSearchHistory()
searchHistoryCount.value = searchRepository.getSearchHistoryCount()
onActionDone.call(ReversibleAction(R.string.search_history_cleared, null))
}
}
fun clearCookies() {
launchJob {
cookieJar.clear()
onActionDone.call(ReversibleAction(R.string.cookies_cleared, null))
}
}
@SuppressLint("RequiresFeature")
fun clearBrowserData() {
launchJob {
try {
loadingKeys.update { it + AppSettings.KEY_WEBVIEW_CLEAR }
val storage = WebStorage.getInstance()
suspendCoroutine { cont ->
WebStorageCompat.deleteBrowsingData(storage) {
cont.resume(Unit)
}
}
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
} finally {
loadingKeys.update { it - AppSettings.KEY_WEBVIEW_CLEAR }
}
}
}
fun clearUpdatesFeed() {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + AppSettings.KEY_UPDATES_FEED_CLEAR }
trackingRepository.clearLogs()
feedItemsCount.value = trackingRepository.getLogsCount()
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
} finally {
loadingKeys.update { it - AppSettings.KEY_UPDATES_FEED_CLEAR }
}
}
}
fun clearMangaData() {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + AppSettings.KEY_CLEAR_MANGA_DATA }
trackingRepository.gc()
val repository = mangaDataRepositoryProvider.get()
repository.cleanupLocalManga()
repository.cleanupDatabase()
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
} finally {
loadingKeys.update { it - AppSettings.KEY_CLEAR_MANGA_DATA }
}
}
}
fun cleanupChapters() {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + AppSettings.KEY_CHAPTERS_CLEAR }
val oldSize = storageUsage.firstNotNull().savedManga.bytes
val chaptersCount = deleteReadChaptersUseCase.invoke()
loadStorageUsage().join()
val newSize = storageUsage.firstNotNull().savedManga.bytes
onChaptersCleanedUp.call(chaptersCount to oldSize - newSize)
} finally {
loadingKeys.update { it - AppSettings.KEY_CHAPTERS_CLEAR }
}
}
}
private fun loadStorageUsage(): Job {
val prevJob = storageUsageJob
return launchJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
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
storageUsage.value = 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(),
),
)
}.also {
storageUsageJob = it
}
}
}

View File

@@ -7,8 +7,12 @@ import androidx.room.RawQuery
import androidx.room.Upsert
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import kotlin.collections.forEach
@Dao
abstract class StatsDao {
@@ -61,4 +65,19 @@ abstract class StatsDao {
protected abstract suspend fun getDurationStatsImpl(
query: SupportSQLiteQuery
): Map<@MapColumn("manga") MangaEntity, @MapColumn("d") Long>
@Query("SELECT * FROM stats ORDER BY started_at LIMIT :limit OFFSET :offset")
protected abstract suspend fun findAll(offset: Int, limit: Int): List<StatsEntity>
fun dumpEnabled(): Flow<StatsEntity> = 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) }
}
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M13,2.05V5.08C16.39,5.57 19,8.47 19,12C19,12.9 18.82,13.75 18.5,14.54L21.12,16.07C21.68,14.83 22,13.45 22,12C22,6.82 18.05,2.55 13,2.05M12,19A7,7 0 0,1 5,12C5,8.47 7.61,5.57 11,5.08V2.05C5.94,2.55 2,6.81 2,12A10,10 0 0,0 12,22C15.3,22 18.23,20.39 20.05,17.91L17.45,16.38C16.17,18 14.21,19 12,19Z" />
</vector>

View File

@@ -1,57 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_scrollFlags="noScroll">
<com.google.android.material.appbar.MaterialToolbar
android:id="@id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_scrollFlags="noScroll">
</com.google.android.material.appbar.MaterialToolbar>
</com.google.android.material.appbar.MaterialToolbar>
</com.google.android.material.appbar.AppBarLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingBottom="@dimen/list_spacing_large"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
tools:listitem="@layout/item_storage_config2" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone"
app:layout_anchor="@id/appbar"
app:layout_anchorGravity="bottom" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone"
app:layout_anchor="@id/appbar"
app:layout_anchorGravity="bottom" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab_add"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:contentDescription="@string/pick_custom_directory"
android:text="@string/add"
app:fabSize="normal"
app:icon="@drawable/ic_add"
app:layout_anchor="@id/recyclerView"
app:layout_anchorGravity="bottom|end"
app:layout_behavior="org.koitharu.kotatsu.core.ui.util.ShrinkOnScrollBehavior"
app:layout_dodgeInsetEdges="bottom" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab_add"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:contentDescription="@string/pick_custom_directory"
android:text="@string/add"
app:fabSize="normal"
app:icon="@drawable/ic_add"
app:layout_anchor="@id/recyclerView"
app:layout_anchorGravity="bottom|end"
app:layout_behavior="org.koitharu.kotatsu.core.ui.util.ShrinkOnScrollBehavior"
app:layout_dodgeInsetEdges="bottom" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:layout_margin="@dimen/screen_padding">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="@dimen/margin_small">
<TextView
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/screen_padding"
android:layout_marginTop="@dimen/screen_padding"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceTitleMedium"
tools:text="@tools:sample/lorem[3]" />
<TextView
android:id="@+id/textView_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/screen_padding"
android:layout_marginTop="@dimen/margin_small"
android:textAppearance="?attr/textAppearanceBodyMedium"
tools:text="@tools:sample/lorem[5]" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/screen_padding"
android:layout_marginTop="@dimen/screen_padding"
android:baselineAligned="false"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/textView_size"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/margin_small"
android:layout_weight="1"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?android:textColorSecondary"
tools:text="250MB / 10GB" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/indicator_size"
android:layout_width="160dp"
android:layout_height="wrap_content"
app:trackCornerRadius="5dp"
app:trackThickness="10dp"
tools:progress="40" />
</LinearLayout>
<TextView
android:id="@+id/textView_info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/screen_padding"
android:layout_marginTop="@dimen/margin_small"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?android:textColorSecondary"
tools:text="Content will be removed within application" />
<Button
android:id="@+id/button_remove"
style="?buttonBarButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginEnd="@dimen/margin_small"
android:text="@string/remove" />
<View
android:id="@+id/spacer"
android:layout_width="match_parent"
android:layout_height="@dimen/margin_small"
tools:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -42,7 +42,7 @@
android:importantForAutofill="no"
android:minHeight="48dp" />
<ImageView
<ImageButton
android:id="@+id/dropdown"
android:layout_width="48dp"
android:layout_height="48dp"

View File

@@ -1,82 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetHeaderBar
android:id="@+id/headerBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/chapters" />
<org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetHeaderBar
android:id="@+id/headerBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/chapters" />
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:baselineAligned="false"
android:orientation="horizontal">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:baselineAligned="false"
android:orientation="horizontal">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabs"
style="?tabSecondaryStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:layout_weight="1"
android:background="@null"
app:tabGravity="start"
app:tabIndicator="@drawable/bg_tab_pill"
app:tabIndicatorAnimationMode="fade"
app:tabIndicatorColor="?colorSurfaceDim"
app:tabIndicatorFullWidth="true"
app:tabIndicatorGravity="stretch"
app:tabInlineLabel="true"
app:tabMinWidth="0dp"
app:tabMode="scrollable"
app:tabUnboundedRipple="true" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabs"
style="?tabSecondaryStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:layout_weight="1"
android:background="@null"
app:tabGravity="start"
app:tabIndicator="@drawable/bg_tab_pill"
app:tabIndicatorAnimationMode="fade"
app:tabIndicatorColor="?colorSurfaceDim"
app:tabIndicatorFullWidth="true"
app:tabIndicatorGravity="stretch"
app:tabInlineLabel="true"
app:tabMinWidth="0dp"
app:tabMode="scrollable"
app:tabUnboundedRipple="true" />
<com.google.android.material.button.MaterialSplitButton
android:id="@+id/split_button_read"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:paddingTop="0dp"
android:paddingBottom="0dp">
<com.google.android.material.button.MaterialSplitButton
android:id="@+id/split_button_read"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:paddingTop="0dp"
android:paddingBottom="0dp">
<Button
android:id="@+id/button_read"
style="?materialSplitButtonLeadingFilledStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="@dimen/read_button_min_width"
android:text="@string/read" />
<Button
android:id="@+id/button_read"
style="?materialSplitButtonLeadingFilledStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="@dimen/read_button_min_width"
android:text="@string/read" />
<Button
android:id="@+id/button_read_menu"
style="?materialSplitButtonIconFilledStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/show_menu"
app:icon="?expandCollapseIndicator"
app:toggleCheckedStateOnClick="false" />
<Button
android:id="@+id/button_read_menu"
style="?materialSplitButtonIconFilledStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/show_menu"
app:icon="?expandCollapseIndicator"
app:toggleCheckedStateOnClick="false" />
</com.google.android.material.button.MaterialSplitButton>
</com.google.android.material.button.MaterialSplitButton>
</LinearLayout>
</LinearLayout>
</com.google.android.material.appbar.MaterialToolbar>
</com.google.android.material.appbar.MaterialToolbar>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<org.koitharu.kotatsu.core.ui.widgets.TouchBlockLayout
android:id="@+id/layout_touchBlock"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</org.koitharu.kotatsu.core.ui.widgets.TouchBlockLayout>
</LinearLayout>

View File

@@ -1,257 +1,330 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetHeaderBar
android:id="@+id/headerBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/filter" />
<org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetHeaderBar
android:id="@+id/headerBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/filter" />
<androidx.core.widget.NestedScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:scrollIndicators="top"
tools:ignore="UnusedAttribute">
<androidx.core.widget.NestedScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:fillViewport="true"
android:scrollIndicators="top|bottom"
tools:ignore="UnusedAttribute">
<LinearLayout
android:id="@+id/layout_body"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="@dimen/margin_small"
android:paddingBottom="@dimen/margin_normal">
<LinearLayout
android:id="@+id/layout_body"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="@dimen/margin_small"
android:paddingBottom="@dimen/margin_normal">
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_order"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/sort_order">
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_order"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/sort_order">
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_order"
style="?materialCardViewOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small">
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_order"
style="?materialCardViewOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small">
<Spinner
android:id="@+id/spinner_order"
android:layout_width="match_parent"
android:layout_height="@dimen/spinner_height"
android:minHeight="?listPreferredItemHeightSmall"
android:paddingHorizontal="8dp" />
<Spinner
android:id="@+id/spinner_order"
android:layout_width="match_parent"
android:layout_height="@dimen/spinner_height"
android:minHeight="?listPreferredItemHeightSmall"
android:paddingHorizontal="8dp" />
</com.google.android.material.card.MaterialCardView>
</com.google.android.material.card.MaterialCardView>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_locale"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:title="@string/language">
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_saved_filters"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:title="@string/saved_filters">
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_locale"
style="?materialCardViewOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small">
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_saved_filters"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
<Spinner
android:id="@+id/spinner_locale"
android:layout_width="match_parent"
android:layout_height="@dimen/spinner_height"
android:minHeight="?listPreferredItemHeightSmall"
android:paddingHorizontal="8dp"
android:popupBackground="@drawable/m3_spinner_popup_background" />
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</com.google.android.material.card.MaterialCardView>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_locale"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:title="@string/language">
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_locale"
style="?materialCardViewOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small">
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_original_locale"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:title="@string/original_language">
<Spinner
android:id="@+id/spinner_locale"
android:layout_width="match_parent"
android:layout_height="@dimen/spinner_height"
android:minHeight="?listPreferredItemHeightSmall"
android:paddingHorizontal="8dp"
android:popupBackground="@drawable/m3_spinner_popup_background" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_original_locale"
style="?materialCardViewOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small">
</com.google.android.material.card.MaterialCardView>
<Spinner
android:id="@+id/spinner_original_locale"
android:layout_width="match_parent"
android:layout_height="@dimen/spinner_height"
android:minHeight="?listPreferredItemHeightSmall"
android:paddingHorizontal="8dp"
android:popupBackground="@drawable/m3_spinner_popup_background" />
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</com.google.android.material.card.MaterialCardView>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_original_locale"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:title="@string/original_language">
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_original_locale"
style="?materialCardViewOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small">
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_genres"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:showMoreButton="true"
app:title="@string/genres">
<Spinner
android:id="@+id/spinner_original_locale"
android:layout_width="match_parent"
android:layout_height="@dimen/spinner_height"
android:minHeight="?listPreferredItemHeightSmall"
android:paddingHorizontal="8dp"
android:popupBackground="@drawable/m3_spinner_popup_background" />
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_genres"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
</com.google.android.material.card.MaterialCardView>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_genresExclude"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:showMoreButton="true"
app:title="@string/genres_exclude">
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_genres"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:showMoreButton="true"
app:title="@string/genres">
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_genresExclude"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_genres"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_types"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:title="@string/type">
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_genresExclude"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:showMoreButton="true"
app:title="@string/genres_exclude">
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_types"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_genresExclude"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_state"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:title="@string/state">
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_author"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:showMoreButton="false"
app:title="@string/author">
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_state"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_author"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_contentRating"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:title="@string/content_rating">
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_types"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:title="@string/type">
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_contentRating"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_types"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_demographics"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:title="@string/demographics">
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_state"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:title="@string/state">
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_demographics"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_state"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_year"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:title="@string/year">
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_contentRating"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:title="@string/content_rating">
<com.google.android.material.slider.Slider
android:id="@+id/slider_year"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:stepSize="1"
app:labelBehavior="gone"
app:tickVisible="true"
tools:value="2020"
tools:valueFrom="1900"
tools:valueTo="2090" />
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_contentRating"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_yearsRange"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:title="@string/years">
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_demographics"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:title="@string/demographics">
<com.google.android.material.slider.RangeSlider
android:id="@+id/slider_yearsRange"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal"
android:stepSize="1"
app:labelBehavior="gone"
app:tickVisible="true"
tools:valueFrom="1900"
tools:valueTo="2090" />
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_demographics"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_year"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:title="@string/year">
<com.google.android.material.slider.Slider
android:id="@+id/slider_year"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:stepSize="1"
app:labelBehavior="gone"
app:tickVisible="true"
tools:value="2020"
tools:valueFrom="1900"
tools:valueTo="2090" />
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
android:id="@+id/layout_yearsRange"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
app:title="@string/years">
<com.google.android.material.slider.RangeSlider
android:id="@+id/slider_yearsRange"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal"
android:stepSize="1"
app:labelBehavior="gone"
app:tickVisible="true"
tools:valueFrom="1900"
tools:valueTo="2090" />
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<com.google.android.material.dockedtoolbar.DockedToolbarLayout
android:id="@+id/docked_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="false">
<LinearLayout
android:id="@+id/layout_bottom"
android:layout_width="match_parent"
android:layout_height="@dimen/m3_comp_toolbar_docked_container_height"
android:gravity="center_vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/button_save"
style="?materialButtonOutlinedStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/margin_small"
android:layout_weight="1"
android:enabled="false"
android:text="@string/save"
tools:enabled="true" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_done"
style="?materialButtonTonalStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_small"
android:layout_weight="1"
android:text="@string/done" />
</LinearLayout>
</com.google.android.material.dockedtoolbar.DockedToolbarLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</LinearLayout>

View File

@@ -1,216 +1,203 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetHeaderBar
android:id="@+id/headerBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/options" />
<org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetHeaderBar
android:id="@+id/headerBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/options" />
<androidx.core.widget.NestedScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:scrollIndicators="top">
<androidx.core.widget.NestedScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:scrollIndicators="top">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="@dimen/margin_normal">
<LinearLayout
android:id="@+id/layout_main"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="@dimen/margin_normal">
<org.koitharu.kotatsu.core.ui.widgets.ListItemTextView
android:id="@+id/button_save_page"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/save_page"
app:drawableStartCompat="@drawable/ic_save" />
<org.koitharu.kotatsu.core.ui.widgets.ListItemTextView
android:id="@+id/button_save_page"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/save_page"
app:drawableStartCompat="@drawable/ic_save" />
<org.koitharu.kotatsu.core.ui.widgets.ListItemTextView
android:id="@+id/button_bookmark"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/bookmark_add"
app:drawableStartCompat="@drawable/ic_bookmark" />
<org.koitharu.kotatsu.core.ui.widgets.ListItemTextView
android:id="@+id/button_bookmark"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/bookmark_add"
app:drawableStartCompat="@drawable/ic_bookmark" />
<org.koitharu.kotatsu.core.ui.widgets.ListItemTextView
android:id="@+id/button_image_server"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawableEnd="@drawable/ic_expand_more_22px"
android:text="@string/image_server"
android:visibility="gone"
app:drawableStartCompat="@drawable/ic_images"
tools:visibility="visible" />
<org.koitharu.kotatsu.core.ui.widgets.ListItemTextView
android:id="@+id/button_image_server"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawableEnd="@drawable/ic_expand_more_22px"
android:text="@string/image_server"
android:visibility="gone"
app:drawableStartCompat="@drawable/ic_images"
tools:visibility="visible" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_normal"
android:text="@string/read_mode"
android:textAppearance="?textAppearanceTitleSmall" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_normal"
android:text="@string/read_mode"
android:textAppearance="?textAppearanceTitleSmall" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/checkableGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_small"
android:baselineAligned="false"
android:orientation="horizontal"
app:selectionRequired="true"
app:singleSelection="true">
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/checkableGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_small"
android:baselineAligned="false"
android:orientation="horizontal"
app:selectionRequired="true"
app:singleSelection="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/button_standard"
style="@style/Widget.Kotatsu.ToggleButton.Vertical"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/standard"
app:icon="@drawable/ic_reader_ltr" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_standard"
style="@style/Widget.Kotatsu.ToggleButton.Vertical"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/standard"
app:icon="@drawable/ic_reader_ltr" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_reversed"
style="@style/Widget.Kotatsu.ToggleButton.Vertical"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/right_to_left"
app:icon="@drawable/ic_reader_rtl" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_reversed"
style="@style/Widget.Kotatsu.ToggleButton.Vertical"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/right_to_left"
app:icon="@drawable/ic_reader_rtl" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_vertical"
style="@style/Widget.Kotatsu.ToggleButton.Vertical"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/vertical"
app:icon="@drawable/ic_reader_vertical" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_vertical"
style="@style/Widget.Kotatsu.ToggleButton.Vertical"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/vertical"
app:icon="@drawable/ic_reader_vertical" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_webtoon"
style="@style/Widget.Kotatsu.ToggleButton.Vertical"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/webtoon"
app:icon="@drawable/ic_script" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_webtoon"
style="@style/Widget.Kotatsu.ToggleButton.Vertical"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/webtoon"
app:icon="@drawable/ic_script" />
</com.google.android.material.button.MaterialButtonToggleGroup>
</com.google.android.material.button.MaterialButtonToggleGroup>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_small"
android:text="@string/reader_mode_hint"
android:textAppearance="?attr/textAppearanceBodySmall" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_small"
android:text="@string/reader_mode_hint"
android:textAppearance="?attr/textAppearanceBodySmall" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switch_double_reader"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:minHeight="?android:listPreferredItemHeightSmall"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:text="@string/use_two_pages_landscape"
android:textAppearance="?textAppearanceListItem"
android:textColor="?colorOnSurfaceVariant"
app:drawableStartCompat="@drawable/ic_split_horizontal" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switch_double_reader"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:minHeight="?android:listPreferredItemHeightSmall"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:text="@string/use_two_pages_landscape"
android:textAppearance="?textAppearanceListItem"
android:textColor="?colorOnSurfaceVariant"
app:drawableStartCompat="@drawable/ic_split_horizontal" />
<TextView
android:id="@+id/text_sensitivity"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_small"
android:text="@string/two_page_scroll_sensitivity"
android:textAppearance="@style/TextAppearance.Kotatsu.GridTitle" />
<TextView
android:id="@+id/text_double_sensitivity"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_small"
android:text="@string/two_page_scroll_sensitivity"
android:textAppearance="@style/TextAppearance.Kotatsu.GridTitle" />
<com.google.android.material.slider.Slider
android:id="@+id/seekbar_sensitivity"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
android:valueFrom="0"
android:valueTo="100"
app:labelBehavior="floating"
tools:value="50" />
<com.google.android.material.slider.Slider
android:id="@+id/slider_double_sensitivity"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
android:valueFrom="0"
android:valueTo="100"
app:labelBehavior="floating"
tools:value="50" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switch_pull_gesture"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:minHeight="?android:listPreferredItemHeightSmall"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:text="@string/enable_pull_gesture_title"
android:textAppearance="?textAppearanceListItem"
android:textColor="?colorOnSurfaceVariant"
app:drawableStartCompat="@drawable/ic_gesture_vertical" />
<org.koitharu.kotatsu.core.ui.widgets.ListItemTextView
android:id="@+id/button_screen_rotate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/rotate_screen"
android:visibility="gone"
app:drawableStartCompat="@drawable/ic_screen_rotation"
tools:visibility="visible" />
<org.koitharu.kotatsu.core.ui.widgets.ListItemTextView
android:id="@+id/button_screen_rotate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/rotate_screen"
android:visibility="gone"
app:drawableStartCompat="@drawable/ic_screen_rotation"
tools:visibility="visible" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switch_screen_lock_rotation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:ellipsize="end"
android:minHeight="?android:listPreferredItemHeightSmall"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:singleLine="true"
android:text="@string/lock_screen_rotation"
android:textAppearance="?textAppearanceListItem"
android:textColor="?colorOnSurfaceVariant"
android:visibility="gone"
app:drawableStartCompat="@drawable/ic_screen_rotation_lock"
tools:visibility="visible" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switch_screen_lock_rotation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:ellipsize="end"
android:minHeight="?android:listPreferredItemHeightSmall"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:singleLine="true"
android:text="@string/lock_screen_rotation"
android:textAppearance="?textAppearanceListItem"
android:textColor="?colorOnSurfaceVariant"
android:visibility="gone"
app:drawableStartCompat="@drawable/ic_screen_rotation_lock"
tools:visibility="visible" />
<org.koitharu.kotatsu.core.ui.widgets.ListItemTextView
android:id="@+id/button_scroll_timer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/automatic_scroll"
app:drawableStartCompat="@drawable/ic_timer" />
<org.koitharu.kotatsu.core.ui.widgets.ListItemTextView
android:id="@+id/button_scroll_timer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/automatic_scroll"
app:drawableStartCompat="@drawable/ic_timer" />
<org.koitharu.kotatsu.core.ui.widgets.ListItemTextView
android:id="@+id/button_color_filter"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/color_correction"
app:drawableStartCompat="@drawable/ic_appearance" />
<org.koitharu.kotatsu.core.ui.widgets.ListItemTextView
android:id="@+id/button_color_filter"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/color_correction"
app:drawableStartCompat="@drawable/ic_appearance" />
<org.koitharu.kotatsu.core.ui.widgets.ListItemTextView
android:id="@+id/button_settings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/settings"
app:drawableStartCompat="@drawable/ic_settings" />
<org.koitharu.kotatsu.core.ui.widgets.ListItemTextView
android:id="@+id/button_settings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/settings"
app:drawableStartCompat="@drawable/ic_settings" />
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.core.widget.NestedScrollView>
</LinearLayout>

View File

@@ -64,6 +64,14 @@
android:text="@string/sync_auth"
app:chipIcon="@drawable/ic_sync" />
<com.google.android.material.chip.Chip
android:id="@+id/chip_directories"
style="@style/Widget.Kotatsu.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/local_manga_directories"
app:chipIcon="@drawable/ic_storage" />
</com.google.android.material.chip.ChipGroup>
</HorizontalScrollView>

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/screen_padding"
android:paddingTop="@dimen/margin_small">
<AutoCompleteTextView
android:id="@+id/autoCompleteTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:layout_toStartOf="@id/dropdown"
tools:ignore="LabelFor" />
<ImageButton
android:id="@+id/dropdown"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:layout_gravity="center_vertical|end"
android:background="?selectableItemBackgroundBorderless"
android:paddingBottom="2dp"
android:scaleType="center"
android:src="@drawable/ic_expand_more"
tools:ignore="ContentDescription" />
</RelativeLayout>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_rename"
android:title="@string/rename" />
<item
android:id="@+id/action_delete"
android:title="@string/delete" />
</menu>

View File

@@ -179,4 +179,43 @@
<string name="incognito_mode_hint">আপনার পড়ার অগ্রগতি সেভ হবে না</string>
<string name="volume_">আওয়াজ%d</string>
<string name="volume_unknown">অজানা ভলিউম</string>
<string name="suggested_queries">সাম্প্রতিক প্রশ্ন</string>
<string name="authors">লেখক</string>
<string name="blocked_by_server_message">আপনি সার্ভার দ্বারা অবরুদ্ধ করা হয়. একটি ভিন্ন নেটওয়ার্ক সংযোগ ব্যবহার করার চেষ্টা করুন (ভিপিএন, প্রক্সি, ইত্যাদি)</string>
<string name="disable">নিষ্ক্রিয় করুন</string>
<string name="sources_disabled">উৎস নিষ্ক্রিয়</string>
<string name="disable_connectivity_check">সংযোগ পরীক্ষা করতে অক্ষম করুন</string>
<string name="ignore_ssl_errors_summary">নেটওয়ার্ক সংস্থানগুলি অ্যাক্সেস করার সময় আপনি যদি কোনও SSL- সম্পর্কিত সমস্যার মুখোমুখি হন তবে আপনি SSL শংসাপত্র যাচাইকরণ অক্ষম করতে পারেন৷ এটি আপনার নিরাপত্তা প্রভাবিত করতে পারে। এই সেটিং পরিবর্তন করার পরে অ্যাপ্লিকেশন পুনরায় চালু করা প্রয়োজন।</string>
<string name="disable_connectivity_check_summary">আপনার সমস্যা থাকলে কানেক্টিভিটি চেক এড়িয়ে যান (যেমন, নেটওয়ার্ক সংযুক্ত থাকা সত্ত্বেও অফলাইন মোডে যাওয়া)</string>
<string name="disable_nsfw_notifications">NSFW বিজ্ঞপ্তি অক্ষম করুন</string>
<string name="disable_nsfw_notifications_summary">NSFW মাঙ্গা আপডেট সম্পর্কে বিজ্ঞপ্তি দেখাবেন না</string>
<string name="tracker_debug_info">নতুন অধ্যায় লগ জন্য পরীক্ষা করা হচ্ছে</string>
<string name="tracker_debug_info_summary">নতুন অধ্যায়গুলির জন্য পটভূমি পরীক্ষা সম্পর্কে তথ্য ডিবাগ করুন</string>
<string name="_new">নতুন</string>
<string name="all_languages">সব ভাষা</string>
<string name="screenshots_block_incognito">ছদ্মবেশী মোড হলে ব্লক করুন</string>
<string name="image_server">পছন্দের ইমেজ সার্ভার</string>
<string name="crop_pages">পৃষ্ঠাগুলি কাটা</string>
<string name="pin">পিন</string>
<string name="unpin">আনপিন করুন</string>
<string name="source_pinned">উৎস পিন করা হয়েছে</string>
<string name="source_unpinned">উৎস আনপিন করা হয়েছে</string>
<string name="sources_unpinned">উৎস আনপিন করা হয়েছে</string>
<string name="sources_pinned">উৎস পিন করা হয়েছে</string>
<string name="recent_sources">সাম্প্রতিক সূত্র</string>
<string name="percent_read">শতকরা কতটুকু করেছেন</string>
<string name="percent_left">শতকরা কতটুকু পড়তে বাকি আছে</string>
<string name="chapters_read">অধ্যায় পড়া</string>
<string name="chapters_left">অধ্যায় বাকি আছে</string>
<string name="external_source">বাহ্যিক/প্লাগইন</string>
<string name="plugin_incompatible">বেমানান প্লাগইন বা অভ্যন্তরীণ ত্রুটি. আপনি প্লাগইন এবং Kotatsu এর সর্বশেষ সংস্করণ ব্যবহার করছেন তা নিশ্চিত করুন৷</string>
<string name="plugin_incompatible_with_cause">প্লাগইন ত্রুটি: %s \n· নিশ্চিত করুন যে আপনি প্লাগইন এবং Kotatsu এর সর্বশেষ সংস্করণ ব্যবহার করছেন</string>
<string name="connection_ok">সংযোগ ঠিক আছে</string>
<string name="invalid_proxy_configuration">অবৈধ প্রক্সি কনফিগারেশন</string>
<string name="show_quick_filters">দ্রুত ফিল্টার দেখান</string>
<string name="show_quick_filters_summary">নির্দিষ্ট পরামিতি দ্বারা মাঙ্গা তালিকা ফিল্টার করার ক্ষমতা প্রদান করে</string>
<string name="sfw">SFW</string>
<string name="skip_all">সব এড়িয়ে যান</string>
<string name="stuck">আটকে গেছে</string>
<string name="source_broken_warning">এই মঙ্গা সূত্রটি\nভাঙ্গা হিসাবে চিহ্নিত। কিছু বৈশিষ্ট্য \nকাজ নাও হতে পারে</string>
</resources>

View File

@@ -740,7 +740,7 @@
<string name="all_sources_enabled">Všechny zdroje jsou povoleny</string>
<string name="rating">Hodnocení</string>
<string name="source">Zdroj</string>
<string name="incognito">Anonymní</string>
<string name="incognito">Inkognito</string>
<string name="reader_info_bar_transparent">Průhledný informační proužek pro čtenáře</string>
<string name="handle_links">Spravovat odkazy</string>
<string name="handle_links_summary">Zpracování odkazů na manga z externích aplikací (např. webového prohlížeče). Může být také nutné povolit ji ručně v systémových nastaveních aplikace</string>
@@ -812,4 +812,75 @@
<string name="creating_backup">Vytváření kopie</string>
<string name="collapse_long_description">Sbalit dlouhý popis</string>
<string name="changelog">Seznam změn</string>
<string name="saved_filters">Uložené filtry</string>
<string name="enter_name">Vložte jméno</string>
<string name="theme_name_expressive">Výrazný (Test)</string>
<string name="pull_to_prev_chapter">Uvolněte pro otevření předešlé kapitoly</string>
<string name="pull_to_next_chapter">Uvolněte pro otevření další kapitoly</string>
<string name="pull_top_no_prev">Žádná předešlá kapitola</string>
<string name="pull_bottom_no_next">Žádná další kapitola</string>
<string name="frequency_every_6_hours">Každých 6 hodin</string>
<string name="reader_navigation_inverted">Převrátit navigační prvky</string>
<string name="reader_navigation_inverted_summary">Vyměňit směr tlačítka hlasitosti a šipek na klávesnici (vlevo/nahoru/dolů/vpravo)</string>
<string name="two_page_scroll_sensitivity">Dvoustranná citlivost posouvání</string>
<string name="enable_pull_gesture_title">Povolit tahová gesta</string>
<string name="enable_pull_gesture_summary">Použijte tahová gesta pro přepínání kapitol v režimu webtoon</string>
<string name="reader_chapter_toast">Zobrazit změnu kapitoly pomocí pop-upu</string>
<string name="reader_chapter_toast_summary">Zobrazit pop-up s názvem kapitoly, když se změní</string>
<string name="badges_in_lists">Odznaky v seznamech</string>
<string name="link_to_manga_on_s">Odkaz na mangu na %s</string>
<string name="no_write_permission_to_file">Nemá oprávnění k ukládání souboru</string>
<string name="error_non_file_uri">Zvolená cesta nemůže být použita, protože neoznačuje soubor nebo adresář</string>
<string name="use_default_cover">Použít výchozí přebal</string>
<string name="pick_manga_page">Vyberte stranu mangy</string>
<string name="pick_custom_file">Vybrat vlastní přebal</string>
<string name="change_cover">Změnit přebal</string>
<string name="page_switch_timer">Strana se změní kažých ~%d sekund</string>
<string name="incognito_mode_hint_nsfw">Tato manga může obsahovat dospělá témata. Chcete použít režim inkognito?</string>
<string name="incognito_for_nsfw">Režim inkognito pro NSFW mangu</string>
<string name="additional_action_required">Dodatečná akce je nutná</string>
<string name="hide_from_main_screen">Skrýt z hlavní obrazovky</string>
<string name="changelog_summary">Historie změn pro nedávno vydané verze</string>
<string name="reader_multitask">Otevřít čtečku ve vlastním okně</string>
<string name="reader_multitask_summary">Umožňuje zároveň otevřít několik různých mang ve vlastních oknech</string>
<string name="theme_name_itsuka">Itsuka</string>
<string name="theme_name_totoro">Totoro</string>
<string name="book_effect">Nažloutlé pozadí (filtr modrého světla)</string>
<string name="local_storage_cleanup">Čištění místního úložišťe</string>
<string name="packup_creation_failed">Nepodařilo se vytvořit zálohu</string>
<string name="main_screen">Hlavní obrazovka</string>
<string name="main_screen_fab">Zobrazit plovoucí tlačítko Pokračovat</string>
<string name="main_screen_fab_summary">Umožňuje pokračovat ve čtení jedním kliknutím. Toto tlačítko se nezobrazí v režimu inkognito nebo když je historie prázdná</string>
<string name="error_corrupted_zip">Poškozený ZIP archiv (%s)</string>
<string name="discord_rpc">Discord Rich Presence</string>
<string name="discord_token">Discord Token</string>
<string name="discord_token_summary">Zadejte svůj Discord Token, abyste zapnuli Rich Presence</string>
<string name="discord_token_description">Zadejte svůj Discord Token nebo klikněte na %s, abyste ho získali pomocí prohlížeče</string>
<string name="discord_token_hint">Vložte svůj Discord Token zde</string>
<string name="discord_rpc_summary">Zobrazit stav čtení na Discordu</string>
<string name="obtain">Získat</string>
<string name="discord_rpc_description">Čte mangu na Kotatsu - aplikace pro čtení mangy</string>
<string name="reading_s">Čtení %s</string>
<string name="read_on_s">Číst na %s</string>
<string name="rpc_skip_nsfw_summary">Nepoužívat RPC pro mangu pro dospělé</string>
<string name="invalid_token">Neplatný token: %s</string>
<string name="show_floating_control_button">Zobrazit plovoucí ovládací tlačítko</string>
<string name="unavailable">Nedostupné</string>
<string name="manga_restricted_description">Tato manga není k dispozici od tohoto zdroje. Zkuste ji vyhledat v jiných zdrojích nebo otevřít v prohlížeči pro více informací</string>
<string name="no_chapters_in_manga">Tato manga neobsahuje žádné kapitoly</string>
<string name="chapters_load_failed">Načítání kapitol selhalo</string>
<string name="telegram_integration">Integrace Telegramu</string>
<string name="test_parser">Vyzkoušet zdroje mang</string>
<string name="rename">Přejmenovat</string>
<string name="save_filter">Uložit filtr</string>
<string name="overwrite">Nahradit</string>
<string name="filter_overwrite_confirm">Filtr pojmenován\"%s\" již existuje. Chcete ho nahradit?</string>
<string name="storage_and_network">Uložiště a síť</string>
<string name="create_or_restore_backup">Vytvořit nebo obnovit zálohu</string>
<string name="data_removal">Odstranění dat</string>
<string name="privacy">Soukromí</string>
<string name="source_broken_warning">Tento zdroj byl označen jako rozbitý. Některé funkce nemusí fungovat</string>
<string name="download_default_directory">Výchozí adresář pro stahování mangy</string>
<string name="private_app_directory_warning">Tento adresář a všechna data v něm budou smazána, pokud odinstalujete aplikaci</string>
<string name="available_pattern">%1$s dostupný</string>
</resources>

View File

@@ -859,4 +859,8 @@
<string name="pull_bottom_no_next">Walang susunod na kabanata</string>
<string name="enable_pull_gesture_title">Paganahin ang paghila na gesture</string>
<string name="enable_pull_gesture_summary">Gamitin ang paghila na gesture para makapagpalit ng kabanata sa webtoon</string>
<string name="saved_filters">Naka-save na mga filter</string>
<string name="enter_name">Ipasok ang pangalan</string>
<string name="reader_chapter_toast">Ipakita ang popup sa pagpalit ng kabanata</string>
<string name="reader_chapter_toast_summary">Magpakita ng pop-up na mensahe na may pamagat ng kabanata kapag binago ito</string>
</resources>

View File

@@ -579,8 +579,8 @@
<string name="reader_actions_summary">Konfigurirajte radnje za dodirna područja zaslona</string>
<string name="switch_pages_volume_buttons_summary">Koristite tipke za glasnoću za prebacivanje stranica</string>
<string name="tap_action">Radnja pri dodiru</string>
<string name="long_tap_action">Radnja dugog dodira</string>
<string name="use_two_pages_landscape">Koristi izgled dvije stranice u pejzažnoj orijentaciji (beta)</string>
<string name="long_tap_action">Radnja pri dugom dodiru</string>
<string name="use_two_pages_landscape">Koristi raspored dviju stranice u poleženom položaju (beta)</string>
<string name="download_option_next_unread_n_chapters">Sljedeći nepročitani %s</string>
<string name="download_option_all_unread">Sva nepročitana poglavlja</string>
<string name="download_option_all_unread_b">Sva nepročitana poglavlja (%s)</string>
@@ -861,4 +861,22 @@
<string name="test_parser">Testiraj izvor mange</string>
<string name="discord_rpc">Discord Rich Presence</string>
<string name="discord_token_summary">Unesi svoj Discord token za uključivanje Rich Presence podataka</string>
<string name="saved_filters">Spremljeni filtri</string>
<string name="enter_name">Upiši ime</string>
<string name="reader_chapter_toast_summary">Prikaži skočnu poruku s naslovom poglavlja kada je promijenjen</string>
<string name="rename">Preimenuj</string>
<string name="save_filter">Spremi filtar</string>
<string name="reader_chapter_toast">Prikaži skočnu poruku za promijenjeno poglavlje</string>
<string name="two_page_scroll_sensitivity">Osjetljivost pomicanja dviju stranica</string>
<string name="frequency_every_6_hours">Svakih 6 sati</string>
<string name="overwrite">Prepiši</string>
<string name="filter_overwrite_confirm">Filtar s imenom „%s” već postoji. Želiš li ga prepisati?</string>
<string name="storage_and_network">Spremište i mreža</string>
<string name="create_or_restore_backup">Stvori ili obnovi sigurnosnu kopiju</string>
<string name="data_removal">Uklanjanje podataka</string>
<string name="privacy">Privatnost</string>
<string name="source_broken_warning">Ovaj izvor mange označen je kao pokvaren. Neke funkcije možda neće raditi</string>
<string name="download_default_directory">Standardni direktorij za preuzimanje manga</string>
<string name="private_app_directory_warning">Ako deinstaliraš aplikaciju, ovaj će se direktorij izbrisati sa svim podacima</string>
<string name="available_pattern">%1$s dostupno</string>
</resources>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="items">
<item quantity="other">%1$d butir</item>
<item quantity="other">%1$d item</item>
</plurals>
<plurals name="new_chapters">
<item quantity="other">%1$d Sub-bab Baru</item>

View File

@@ -865,4 +865,22 @@
<string name="pull_bottom_no_next">Tidak ada bab berikutnya</string>
<string name="enable_pull_gesture_title">Aktifkan gerakan tarik</string>
<string name="enable_pull_gesture_summary">Gunakan gerakan tarik untuk pindah bab di webtoon</string>
<string name="two_page_scroll_sensitivity">Sensitivitas Gulir Dua Halaman</string>
<string name="saved_filters">Filter tersimpan</string>
<string name="enter_name">Masukkan nama</string>
<string name="reader_chapter_toast">Tampilkan popup perubahan bab</string>
<string name="reader_chapter_toast_summary">Tampilkan pesan pop-up dengan judul bab saat diubah</string>
<string name="rename">Ganti nama</string>
<string name="save_filter">Simpan filter</string>
<string name="overwrite">Timpa</string>
<string name="filter_overwrite_confirm">Filter bernama \"%s\" sudah ada. Ingin menimpanya?</string>
<string name="storage_and_network">Penyimpanan dan jaringan</string>
<string name="create_or_restore_backup">Buat atau pulihkan cadangan</string>
<string name="data_removal">Penghapusan data</string>
<string name="source_broken_warning">Sumber manga ini telah ditandai rusak. Beberapa fitur mungkin tidak berfungsi</string>
<string name="privacy">Pribadi</string>
<string name="frequency_every_6_hours">Setiap 6 jam</string>
<string name="download_default_directory">Direktori default untuk mengunduh manga</string>
<string name="private_app_directory_warning">Direktori ini beserta semua datanya akan dihapus jika Anda menghapus aplikasi</string>
<string name="available_pattern">%1$s tersedia</string>
</resources>

View File

@@ -866,4 +866,22 @@
<string name="pull_bottom_no_next">Nessun capitolo successivo</string>
<string name="enable_pull_gesture_title">Abilita gesto di scorrimento</string>
<string name="enable_pull_gesture_summary">Abilita gesto di scorrimento per cambiare capitolo in webtoon</string>
<string name="two_page_scroll_sensitivity">Sensibilità di scorrimento a due pagine</string>
<string name="saved_filters">Filtri salvati</string>
<string name="enter_name">Inserisci nome</string>
<string name="reader_chapter_toast">Mostra popup di cambio capitolo</string>
<string name="reader_chapter_toast_summary">Mostra un messaggio pop-up con un titolo del capitolo quando è cambiato</string>
<string name="rename">Rinomina</string>
<string name="save_filter">Salva filtro</string>
<string name="overwrite">Sovrascrivi</string>
<string name="filter_overwrite_confirm">Un filtro di nome \"%s\" esiste già. Vuoi sovrascriverlo?</string>
<string name="storage_and_network">Memoria e rete</string>
<string name="create_or_restore_backup">Crea o ripristina un backup</string>
<string name="data_removal">Rimozione dei dati</string>
<string name="privacy">Privacy</string>
<string name="source_broken_warning">Questa fonte manga è stata contrassegnata come non funzionante. Alcune funzioni potrebbero non essere disponibili</string>
<string name="frequency_every_6_hours">Ogni 6 ore</string>
<string name="download_default_directory">Cartella predefinita per scaricare manga</string>
<string name="private_app_directory_warning">Questa cartella e tutti i suoi dati verranno eliminati se disinstalli l\'applicazione</string>
<string name="available_pattern">%1$s disponibile</string>
</resources>

View File

@@ -93,7 +93,7 @@
<string name="manga_shelf">本棚</string>
<string name="recent_manga">最近</string>
<string name="pages_animation">ページアニメーション</string>
<string name="manga_save_location">ダウンロード用のフォルダ</string>
<string name="manga_save_location">ダウンロードフォルダ</string>
<string name="not_available">利用不可</string>
<string name="cannot_find_available_storage">使用可能なストレージがありません</string>
<string name="other_storage">その他のストレージ</string>
@@ -101,7 +101,7 @@
<string name="all_favourites">全てのお気に入り</string>
<string name="read_later">後で読む</string>
<string name="updates">更新</string>
<string name="text_feed_holder">あなたが読んでいるものの新しいチャプターがここに示されています</string>
<string name="text_feed_holder">あなたが読んでいるものの新しいがここに示されています</string>
<string name="search_results">の検索結果</string>
<string name="size_s">サイズ:%s</string>
<string name="clear_updates_feed">更新フィードをクリア</string>
@@ -249,7 +249,7 @@
<string name="send">送信</string>
<string name="disable_all">すべて無効にする</string>
<string name="appwidget_recent_description">最近読んだ漫画</string>
<string name="use_fingerprint">指紋がある場合、指紋を使用する</string>
<string name="use_fingerprint">利用可能な場合、指紋認証を使用する</string>
<string name="appwidget_shelf_description">お気に入りの漫画</string>
<string name="report">報告</string>
<string name="status_reading">読書</string>
@@ -316,7 +316,7 @@
<string name="reader_control_ltr">人間工学に基づいたリーダーコントロール</string>
<string name="history_shortcuts">最近のマンガのショートカットを表示</string>
<string name="history_shortcuts_summary">アプリケーションアイコンを長押しして最近のマンガを利用できるようにする</string>
<string name="reader_control_ltr_summary">右端をタップするか、右キーを押すと常に次のページに切り替わります</string>
<string name="reader_control_ltr_summary">ページ切り替え方向をリーダーモードに調整しないでください。例えば、右キーを押すと常に次のページに切り替わります。このオプションはハードウェア入力デバイスにのみ影響します。</string>
<string name="color_correction">色補正</string>
<string name="brightness">輝度</string>
<string name="contrast">コントラスト</string>
@@ -565,7 +565,7 @@
<string name="show_labels_in_navbar">ナビゲーションバーにラベルを表示する</string>
<string name="pages_saving">ページを保存</string>
<string name="remove_from_history">履歴から削除</string>
<string name="preferred_download_format">希望するダウンロード形式</string>
<string name="preferred_download_format">推奨ダウンロード形式</string>
<string name="single_cbz_file">単一のCBZファイル</string>
<string name="multiple_cbz_files">複数のCBZファイル</string>
<string name="other_manga">他のマンガ</string>
@@ -671,4 +671,148 @@
<string name="genre">ジャンル</string>
<string name="download_added">ダウンロードに追加</string>
<string name="chapter_selection_hint">チャプターリストの項目を長押しすると、ダウンロードするチャプターを選択できます。</string>
<string name="frequency_every_6_hours">6時間毎に</string>
<string name="available_pattern">%1$sが有効</string>
<string name="no_chapters_in_manga">この漫画には利用可能なチャプターが含まれていません</string>
<string name="chapters_load_failed">チャプターリストの読み込みに失敗</string>
<string name="telegram_integration">Telegramで連携</string>
<string name="test_parser">漫画のソースをテストする</string>
<string name="rename">リネーム</string>
<string name="save_filter">フィルターを保存</string>
<string name="overwrite">上書き</string>
<string name="filter_overwrite_confirm">「%s」という名前のフィルターが既に存在します。上書きしますか</string>
<string name="storage_and_network">ストレージとネットワーク</string>
<string name="create_or_restore_backup">バックアップを作成または復元する</string>
<string name="data_removal">データ削除</string>
<string name="privacy">プライバシー</string>
<string name="source_broken_warning">この漫画ソースは破損しているとマークされています。一部の機能が動作しない可能性があります</string>
<string name="download_default_directory">漫画ダウンロードのデフォルトディレクトリ</string>
<string name="private_app_directory_warning">アプリケーションをアンインストールすると、このディレクトリとすべてのデータが削除されます</string>
<string name="author">著者</string>
<string name="rating">評価</string>
<string name="source">ソース</string>
<string name="translation">翻訳</string>
<string name="captcha_required_message">このソースを続行するにはキャプチャを解決する必要があります</string>
<string name="ask_every_time">いつでも聞いて</string>
<string name="screen_orientation">画面の向き</string>
<string name="portrait">肖像画</string>
<string name="landscape">風景</string>
<string name="by_name_reverse">名前を反転</string>
<string name="content_rating">コンテンツレーティング</string>
<string name="global_search">グローバルサーチ</string>
<string name="disable_captcha_notifications">Captcha通知を無効にする</string>
<string name="disable_captcha_notifications_summary">このソースのCaptchaに関する通知は届きませんが、これによりバックグラウンド操作新章の確認、おすすめ情報の取得などが中断される可能性があります</string>
<string name="chapter_volume_number">巻 %1$s 章 %2$s</string>
<string name="chapter_number">章%s</string>
<string name="unnamed_chapter">名前のない章</string>
<string name="search_disabled_sources">無効化されたソースを検索する</string>
<string name="error_details">エラーの詳細</string>
<string name="error_disclaimer_manga">ウェブブラウザで漫画を開いて、そのソースで利用可能かどうかを確認してください。</string>
<string name="error_disclaimer_app_outdated">お使いのKotatsuのバージョンが古くなっているようです。最新のバージョンをインストールして、利用可能なすべての修正プログラムを入手してください。</string>
<string name="error_disclaimer_report">開発者へバグ報告を提出できます。これにより問題の調査と修正に役立ちます。</string>
<string name="link_to_manga_on_s">%sの漫画へのリンク</string>
<string name="link_to_manga_in_app">Kotatsuの中の漫画へのリンク</string>
<string name="clear_browser_data">ブラウザのデータを消去する</string>
<string name="clear_browser_data_summary">キャッシュやクッキーなどのブラウザデータを消去してください。警告:マンガソースでの認証が無効になる可能性があります</string>
<string name="no_write_permission_to_file">ファイルへの書き込み権限がありません</string>
<string name="exclude_nsfw_from_suggestions_summary">成人向け漫画はおすすめに表示されません。このオプションは一部のソースでは正確に機能しない場合があります</string>
<string name="include_disabled_sources">無効化されたソースを含める</string>
<string name="suggestions_disabled_sources_summary">すべてのマンガソースからの提案を表示する(無効化されているものも含む)</string>
<string name="tags_warnings">危険なジャンルを強調する</string>
<string name="tags_warnings_summary">ほとんどのユーザーにとって不適切と思われるジャンルを強調表示する</string>
<string name="error_non_file_uri">選択されたパスはファイルまたはディレクトリを指していないため使用できません</string>
<string name="manga_override_hint">これらの変更は、アプリ内でのマンガの表示方法に影響を与えます</string>
<string name="use_default_cover">デフォルトの表紙を使用する</string>
<string name="pick_manga_page">漫画のページを選ぶ</string>
<string name="pick_custom_file">カスタムファイルを選択</string>
<string name="change_cover">カバーを変える</string>
<string name="page_switch_timer">ページは約%d秒ごとに切り替わります</string>
<string name="dont_ask_again">二度と聞くな</string>
<string name="incognito_mode_hint_nsfw">この漫画には成人向けの内容が含まれている可能性があります。シークレットモードを使用しますか?</string>
<string name="incognito_for_nsfw">成人向け漫画のシークレットモード</string>
<string name="additional_action_required">追加の操作が必要です</string>
<string name="hide_from_main_screen">メイン画面から非表示にする</string>
<string name="changelog">変更履歴</string>
<string name="changelog_summary">最近リリースされたバージョンの変更履歴</string>
<string name="collapse">折りたたむ</string>
<string name="expand">拡張する</string>
<string name="adblock">ブラウザで広告をブロックする</string>
<string name="adblock_summary">組み込みブラウザでの広告ブロック(ベータ版)</string>
<string name="collapse_long_description">長い説明を折りたたむ</string>
<string name="creating_backup">バックアップを作成する</string>
<string name="share_backup">バックアップを共有する</string>
<string name="reader_multitask">別のタスクでリーダーを開く</string>
<string name="reader_multitask_summary">複数の異なる漫画を開いたまま、複数のリーダーを同時に保持できます</string>
<string name="theme_name_itsuka">トピックス</string>
<string name="theme_name_totoro">トトロ</string>
<string name="book_effect">黄色がかった背景(青いフィルター)</string>
<string name="local_storage_cleanup">ローカルストレージのクリーンアップ</string>
<string name="packup_creation_failed">バックアップの作成に失敗しました</string>
<string name="main_screen">メイン画面</string>
<string name="main_screen_fab">フローティングの「続行」ボタンを表示</string>
<string name="main_screen_fab_summary">ワンクリックで読み続けられます。このボタンはシークレットモード時や履歴が空の時には表示されません</string>
<string name="error_corrupted_zip">破損したZIPアーカイブ(%s)</string>
<string name="discord_rpc">Discord リッチプレゼンス</string>
<string name="discord_token">Discordトークン</string>
<string name="discord_token_summary">リッチプレゼンスを有効にするには、Discordトークンを入力してください</string>
<string name="discord_token_description">Discord トークンを入力するか、%s をクリックしてブラウザーで取得します</string>
<string name="discord_token_hint">ここにDiscordトークンを貼り付けてください</string>
<string name="discord_rpc_summary">Discordで読み取りステータスを表示</string>
<string name="obtain">アクセス</string>
<string name="discord_rpc_description">Kotatsuで漫画を読む - 漫画リーダーアプリ</string>
<string name="reading_s">読書 %s</string>
<string name="read_on_s">%s で読む</string>
<string name="rpc_skip_nsfw_summary">成人向けコンテンツに RPC を使用しないでください</string>
<string name="invalid_token">無効なトークン: %s</string>
<string name="show_floating_control_button">「続行」ボタンをフローティングで表示</string>
<string name="unavailable">利用案内</string>
<string name="manga_restricted_description">このマンガは、このソースで読み込むことはできません。 他のソースで検索するか、ブラウザで開くと、より多くの情報が得られます</string>
<string name="saved_filters">保存フィルター</string>
<string name="chapters_grid_view">グリッドビュー</string>
<string name="enter_name">名前を入力してください</string>
<string name="nsfw_16">16歳以上</string>
<string name="theme_name_expressive">エクスプレス(テスト)</string>
<string name="pull_to_prev_chapter">前の章を開く</string>
<string name="pull_to_next_chapter">次の章を開く</string>
<string name="state_abandoned">ドロップ</string>
<string name="speed_value">x%.1f</string>
<string name="lock_screen_rotation">ロック スクリーンの回転</string>
<string name="manage_sources">ソースの管理</string>
<string name="manual">マニュアル</string>
<string name="available_d">利用可能: %1$d</string>
<string name="reader_optimize_summary">画面外ページの品質を下げ、メモリ使用量を削減する</string>
<string name="state">ステータス</string>
<string name="error_filter_states_genre_not_supported">両方のジャンルや状態のフィルタリングは、このソースではサポートされていません</string>
<string name="volume_unknown">未知のボリューム</string>
<string name="vertical">プロフィール</string>
<string name="last_read">最近の投稿</string>
<string name="reader_actions">リーダーの動作</string>
<string name="none">なし</string>
<string name="two_page_scroll_sensitivity">2ページスクロール感度</string>
<string name="ask_for_dest_dir_every_time">毎回、目的のディレクトリを指定してください</string>
<string name="default_page_save_dir">デフォルトページ保存ディレクトリ</string>
<string name="location">アクセス</string>
<string name="reading_stats">統計を読む</string>
<string name="all_time">すべての時間</string>
<string name="pages_read_s">ページの読み込み: %s</string>
<string name="migrate_confirmation">「%2$s」から「%3$s」を「%4$s」に置き換えられます</string>
<string name="chapters_deleted_pattern">%1$s を削除し、%2$s をクリア</string>
<string name="split_by_translations">翻訳によるスプリット</string>
<string name="order_oldest">最新記事</string>
<string name="long_ago_read">昔読んだ</string>
<string name="enable_source">ソースを有効にする</string>
<string name="last_used">最後の使用</string>
<string name="show_updated">更新情報</string>
<string name="more_frequently">より頻繁に</string>
<string name="recent_queries">最近の質問</string>
<string name="suggested_queries">提案されたクエリ</string>
<string name="sources_disabled">無効なソース</string>
<string name="disable_connectivity_check">接続チェックを無効にする</string>
<string name="tracker_debug_info">新しいチャプターログの確認</string>
<string name="tracker_debug_info_summary">新規チャプター向けバックグラウンドチェックに関するデバッグ情報</string>
<string name="_new">新しい</string>
<string name="image_server">優先画像サーバ</string>
<string name="pin">ピン</string>
<string name="unpin">ピンを外す</string>
<string name="source_pinned">ソース ピン留め</string>
</resources>

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="new_chapters">
<item quantity="one">%1$d nieuwe hoofdstuk</item>
<item quantity="other">%1$d nieuwe hoofdstukken</item>
</plurals>
<plurals name="chapters">
<item quantity="one">%1$d hoofdstuk</item>
<item quantity="other">%1$d hoofdstukken</item>
</plurals>
<plurals name="minutes_ago">
<item quantity="one">%1$d minuut geleden</item>
<item quantity="other">%1$d minuten geleden</item>
</plurals>
<plurals name="hours_ago">
<item quantity="one">%1$d uur geleden</item>
<item quantity="other">%1$d uren geleden</item>
</plurals>
<plurals name="days_ago">
<item quantity="one">%1$d dag geleden</item>
<item quantity="other">%1$d dagen geleden</item>
</plurals>
<plurals name="months_ago">
<item quantity="one">%1$d maand geleden</item>
<item quantity="other">%1$d maanden geleden</item>
</plurals>
<plurals name="hours">
<item quantity="one">%1$d uur</item>
<item quantity="other">%1$d uren</item>
</plurals>
<plurals name="minutes">
<item quantity="one">%1$d minuut</item>
<item quantity="other">%1$d minuten</item>
</plurals>
</resources>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="chapters">Hoofdstukken</string>
<string name="new_chapters">Nieuwe hoofdstukken</string>
<string name="favourites">Favorieten</string>
<string name="history">Geschiedenis</string>
<string name="error_occurred">Er is een fout opgetreden</string>
<string name="network_error">Netwerk fout</string>
<string name="details">Details</string>
<string name="detailed_list">Gedetailleerde lijst</string>
<string name="grid">Rooster</string>
<string name="list_mode">Lijstmodus</string>
<string name="settings">Instellingen</string>
<string name="remote_sources">Mangabronnen</string>
<string name="loading_">Bezig met laden…</string>
<string name="computing_">Computeren…</string>
<string name="chapter_d_of_d">Hoofdstuk %1$d van %2$d</string>
<string name="close">Sluiten</string>
<string name="try_again">Probeer het opnieuw</string>
<string name="retry">Opnieuw proberen</string>
<string name="clear_history">Geschiedenis wissen</string>
</resources>

View File

@@ -756,7 +756,7 @@
<string name="telegram_chat_id">ID do bate-papo do telegram</string>
<string name="open_telegram_bot">Abra o bot do Telegram</string>
<string name="clear_database">Limpar banco de dados</string>
<string name="clear_database_summary">Excluir informações sobre mangás que não estão sendo utilizadas</string>
<string name="clear_database_summary">Excluir informações sobre mangás que não estão sendo utilizados</string>
<string name="test_connection">Teste de conexão</string>
<string name="backup_tg_echo">Mensagem de teste</string>
<string name="translation">Tradução</string>
@@ -800,7 +800,7 @@
<string name="tags_warnings">Destacar gêneros perigosos</string>
<string name="tags_warnings_summary">Destacar gêneros que podem ser inapropriados para a maioria dos usuários</string>
<string name="nsfw_16">+16</string>
<string name="exclude_nsfw_from_suggestions_summary">Mangás adultos não serão exibidas nas sugestões. Esta opção pode funcionar de forma imprecisa com algumas fontes</string>
<string name="exclude_nsfw_from_suggestions_summary">Mangás adultos não serão exibidos nas sugestões. Esta opção pode funcionar de forma imprecisa com algumas fontes</string>
<string name="include_disabled_sources">Incluir fontes desabilitadas</string>
<string name="suggestions_disabled_sources_summary">Mostrar sugestões de todas as fontes, incluindo as desabilitadas</string>
<string name="manga_override_hint">Essas mudanças afetarão como a obra é exibida no aplicativo</string>
@@ -847,7 +847,7 @@
<string name="invalid_token">Token inválido: %s</string>
<string name="show_floating_control_button">Mostrar botão de controle flutuante</string>
<string name="unavailable">Indisponível</string>
<string name="manga_restricted_description">Este mangá não está disponível para leitura nessa fonte. Tente pesquisar por ele em outras fontes ou abrir em um navegador para mais informação</string>
<string name="manga_restricted_description">Este mangá não está disponível para leitura nessa fonte. Tente pesquisar por ele em outras fontes ou abrir em um navegador para mais informações</string>
<string name="no_chapters_in_manga">Este mangá não contém nenhum capítulo</string>
<string name="chapters_load_failed">Falha ao carregar lista de capítulos</string>
<string name="telegram_integration">Integração com Telegram</string>
@@ -856,4 +856,29 @@
<string name="discord_rpc">Discord Rich Presence</string>
<string name="discord_token_description">Insira seu Token do Discord ou clique em %s para obtê-lo usando o navegador</string>
<string name="read_on_s">Leia em %s</string>
<string name="saved_filters">Filtros salvos</string>
<string name="pull_to_prev_chapter">Soltar para abrir o capítulo anterior</string>
<string name="pull_to_next_chapter">Soltar para abrir o próximo capítulo</string>
<string name="pull_top_no_prev">Capítulo anterior indisponível</string>
<string name="pull_bottom_no_next">Próximo capítulo indisponível</string>
<string name="two_page_scroll_sensitivity">Sensibilidade de Rolagem Dupla</string>
<string name="enable_pull_gesture_title">Ativar gesto de puxar</string>
<string name="enable_pull_gesture_summary">Puxe para trocar de capítulo em webtoons</string>
<string name="reader_chapter_toast">Exibir mensagem ao mudar de capítulo</string>
<string name="reader_chapter_toast_summary">Exibir título do capítulo ao mudar de capítulo</string>
<string name="rename">Renomear</string>
<string name="save_filter">Salvar filtro</string>
<string name="overwrite">Sobrescrever</string>
<string name="filter_overwrite_confirm">O filtro \"%s\" já existe. Deseja substituí-lo?</string>
<string name="storage_and_network">Armazenamento e rede</string>
<string name="create_or_restore_backup">Criar ou restaurar um backup</string>
<string name="privacy">Privacidade</string>
<string name="enter_name">Insira um nome</string>
<string name="test_parser">Testar fonte de mangás</string>
<string name="data_removal">Remoção de dados</string>
<string name="source_broken_warning">Esta fonte de mangá está quebrada. Algumas funções podem não funcionar</string>
<string name="frequency_every_6_hours">A cada 6 horas</string>
<string name="download_default_directory">Diretório padrão onde baixar os mangás</string>
<string name="private_app_directory_warning">Esse diretório e todo seu conteúdo será deletado se desinstalar a aplicação</string>
<string name="available_pattern">%1$s disponível</string>
</resources>

View File

@@ -858,4 +858,25 @@
<string name="no_chapters_in_manga">Esse manga não contém capítulos</string>
<string name="chapters_load_failed">Falha ao carregar lista de capítulos</string>
<string name="telegram_integration">Integração Telegram</string>
<string name="saved_filters">Filtros salvos</string>
<string name="enter_name">Digite o nome</string>
<string name="pull_to_prev_chapter">Solte para abrir o capítulo anterior</string>
<string name="pull_to_next_chapter">Solte para abrir o próximo capítulo</string>
<string name="pull_top_no_prev">Nenhum capítulo anterior</string>
<string name="pull_bottom_no_next">Sem mais capítulos</string>
<string name="two_page_scroll_sensitivity">Sensibilidade de rolagem de página dupla</string>
<string name="enable_pull_gesture_title">Ativar gesto de puxar</string>
<string name="enable_pull_gesture_summary">Use o gesto de puxar para mudar de capítulo na webtoon</string>
<string name="reader_chapter_toast">Mostrar pop-up de mudança de capítulo</string>
<string name="reader_chapter_toast_summary">Mostrar uma mensagem pop-up com o título do capítulo quando ele for alterado</string>
<string name="test_parser">Teste fonte do mangá</string>
<string name="rename">Renomear</string>
<string name="save_filter">Salvar filtro</string>
<string name="overwrite">Sobrescrever</string>
<string name="filter_overwrite_confirm">Já existe um filtro chamado \"%s\". Você quer sobrescrevê-lo?</string>
<string name="storage_and_network">Armazenamento e internet</string>
<string name="create_or_restore_backup">Criar e restaurar backup</string>
<string name="data_removal">Remover dados</string>
<string name="privacy">Privacidade</string>
<string name="source_broken_warning">Esta fonte de mangá foi marcada como quebrada. Alguns recursos podem não funcionar</string>
</resources>

View File

@@ -859,4 +859,27 @@
<string name="no_chapters_in_manga">Эта манга не содержит глав</string>
<string name="chapters_load_failed">Не удалось загрузить список глав</string>
<string name="telegram_integration">Интеграция с Telegram</string>
<string name="pull_to_prev_chapter">Отпустите, чтобы открыть предыдущую главу</string>
<string name="pull_top_no_prev">Нет предыдущей главы</string>
<string name="pull_bottom_no_next">Нет следующей главы</string>
<string name="two_page_scroll_sensitivity">Чувствительность прокрутки двух страниц</string>
<string name="enable_pull_gesture_title">Включить жест перетягивания</string>
<string name="enable_pull_gesture_summary">Используйте жест перетаскивания для переключения глав в вебтуне</string>
<string name="test_parser">Проверка источников манги</string>
<string name="pull_to_next_chapter">Отпустите, чтобы открыть следующую главу</string>
<string name="reader_chapter_toast">Отображать сообщение о смене главы</string>
<string name="reader_chapter_toast_summary">Показывать всплывающее сообщение с названием главы при переходе между главами</string>
<string name="rename">Переименовать</string>
<string name="save_filter">Сохранить фильтр</string>
<string name="overwrite">Перезаписать</string>
<string name="filter_overwrite_confirm">Фильтр с именем \"%s\" уже существует. Перезаписать?</string>
<string name="storage_and_network">Хранилище и сеть</string>
<string name="create_or_restore_backup">Создать или восстановить резервную копию</string>
<string name="data_removal">Удаление данных</string>
<string name="privacy">Приватность</string>
<string name="source_broken_warning">Данный источник манги помечен как сломанный. Некоторые функции могут не работать</string>
<string name="download_default_directory">Каталог по умолчанию для загрузки манги</string>
<string name="private_app_directory_warning">Данный каталог вместе со всеми данными будет удалён при удалении приложения</string>
<string name="available_pattern">%1$s доступно</string>
<string name="enter_name">Введите имя</string>
</resources>

View File

@@ -30,7 +30,7 @@
<string name="download_complete">İndirildi</string>
<string name="downloads">İndirmeler</string>
<string name="by_name">Ad</string>
<string name="updated">Güncellendi</string>
<string name="updated">Güncellenenler</string>
<string name="newest">En yeniler</string>
<string name="by_rating">Puanlama</string>
<string name="filter">Filtre</string>
@@ -207,7 +207,7 @@
<string name="only_using_wifi">Yalnızca Wi-Fi\'de</string>
<string name="preload_pages">Sayfaları önceden yükle</string>
<string name="logged_in_as">%s olarak oturum açıldı</string>
<string name="nsfw">18+</string>
<string name="nsfw">+18</string>
<string name="various_languages">Çeşitli diller</string>
<string name="search_chapters">Bölüm bul</string>
<string name="chapters_empty">Bu mangada bölüm yok</string>
@@ -503,7 +503,7 @@
<string name="lock_screen_rotation">Ekran döndürmeyi kilitle</string>
<string name="error_search_not_supported">Arama bu manga kaynağı tarafından desteklenmemektedir</string>
<string name="manual">Manuel</string>
<string name="disable_nsfw_summary">Eğer mümkünse yetişkin içerik ve NSFW kaynaklarını arama listesinden kaldır</string>
<string name="disable_nsfw_summary">NSFW kaynakları devre dışı bırak ve yetişkinlere yönelik mangaları listeden gizle</string>
<string name="available_d">Mevcut:%1$d</string>
<string name="state">Durum</string>
<string name="error_filter_states_genre_not_supported">Hem türlere hem de duruma göre filtreleme bu kaynak tarafından desteklenmiyor</string>
@@ -867,4 +867,21 @@
<string name="enable_pull_gesture_title">Çekme hareketini etkinleştir</string>
<string name="enable_pull_gesture_summary">Webtoon modunda bölüm değiştirmek için çekme hareketi kullan</string>
<string name="two_page_scroll_sensitivity">İki Sayfa Kaydırma Hassaslığı</string>
<string name="saved_filters">Kaydedilen filtreler</string>
<string name="enter_name">Ad girin</string>
<string name="reader_chapter_toast">Bölüm değişikliği açılır penceresini göster</string>
<string name="reader_chapter_toast_summary">Değiştirildiğinde bölüm başlığını içeren bir açılır pencere mesajı göster</string>
<string name="rename">Yeniden adlandır</string>
<string name="save_filter">Filtreyi kaydet</string>
<string name="overwrite">Üzerine yaz</string>
<string name="filter_overwrite_confirm">\"%s\" adında bir filtre zaten var. Üzerine yazmak istiyor musunuz?</string>
<string name="storage_and_network">Depolama ve ağ</string>
<string name="create_or_restore_backup">Yedekleme oluştur veya geri yükle</string>
<string name="data_removal">Verilerin silinmesi</string>
<string name="privacy">Gizlilik</string>
<string name="source_broken_warning">Bu manga kaynağı bozuk olarak işaretlendi. Bazı özellikler çalışmayabilir</string>
<string name="download_default_directory">Mangaların indirileceği varsayılan dizin</string>
<string name="private_app_directory_warning">Uygulamayı kaldırırsanız, bu dizin ve içindeki tüm veriler silinecektir</string>
<string name="available_pattern">%1$s var</string>
<string name="frequency_every_6_hours">6 saatte bir</string>
</resources>

View File

@@ -832,9 +832,9 @@
<string name="error_disclaimer_report">Ви можете надіслати звіт про помилку розробникам. Це допоможе нам виправити проблему.</string>
<string name="error_disclaimer_app_outdated">Схоже, що ваша версія Kotatsu застаріла. Будь ласка, установіть останню версію, щоб отримати всі доступні виправлення.</string>
<string name="error_disclaimer_manga">Спробуйте відкрити манґу в браузері, щоб переконатися, що вона доступна джерелом.</string>
<string name="handle_links_summary">Обробка посилань на мангу з зовнішніх програм (наприклад, веб-браузера). Можливо, вам також доведеться ввімкнути цю функцію вручну в системних налаштуваннях програми.</string>
<string name="disable_captcha_notifications_summary">Ви не будете отримувати повідомлення про вирішення CAPTCHA для цього джерела, але це може призвести до порушення фонових операцій (перевірка нових розділів, отримання рекомендацій тощо).</string>
<string name="clear_browser_data_summary">Очистити дані браузера, такі як кеш і файли cookie. Попередження: авторизація в джерелах манги може стати недійсною.</string>
<string name="handle_links_summary">Обробка посилань на мангу з зовнішніх програм (наприклад, веб-браузера). Можливо, вам також доведеться ввімкнути цю функцію вручну в системних налаштуваннях програми</string>
<string name="disable_captcha_notifications_summary">Ви не будете отримувати повідомлення про вирішення CAPTCHA для цього джерела, але це може призвести до порушення фонових операцій (перевірка нових розділів, отримання рекомендацій тощо)</string>
<string name="clear_browser_data_summary">Очистити дані браузера, такі як кеш і файли cookie. Попередження: авторизація в джерелах манги може стати недійсною</string>
<string name="local_storage_cleanup">Очищення локального сховища</string>
<string name="packup_creation_failed">Не вдалося створити резервну копію</string>
<string name="main_screen">Головний екран</string>
@@ -867,4 +867,17 @@
<string name="enable_pull_gesture_title">Увімкнути жест потягування</string>
<string name="enable_pull_gesture_summary">Використовуйте жест потягування для перемикання розділів у вебтуні</string>
<string name="two_page_scroll_sensitivity">Чутливість прокручування на дві сторінки</string>
<string name="saved_filters">Збережені фільтри</string>
<string name="enter_name">Введіть ім\'я</string>
<string name="reader_chapter_toast">Показати спливаюче вікно зміни розділу</string>
<string name="reader_chapter_toast_summary">Показувати спливаюче повідомлення з назвою розділу, коли її змінено</string>
<string name="rename">Перейменувати</string>
<string name="save_filter">Зберегти фільтр</string>
<string name="overwrite">Перезаписати</string>
<string name="filter_overwrite_confirm">Фільтр з назвою \"%s\" вже існує. Перезаписати його?</string>
<string name="storage_and_network">Зберігання та мережа</string>
<string name="create_or_restore_backup">Створення або відновлення резервної копії</string>
<string name="data_removal">Вилучення даних</string>
<string name="privacy">Конфіденційність</string>
<string name="source_broken_warning">Це джерело манги позначено як непрацююче. Деякі функції можуть не працювати</string>
</resources>

View File

@@ -864,4 +864,22 @@
<string name="pull_bottom_no_next">Không còn chương nào để xem tiếp</string>
<string name="enable_pull_gesture_title">Bật cử chỉ kéo - thả</string>
<string name="enable_pull_gesture_summary">Sử dụng cử chỉ tay kéo - thả để chuyển chương trong chế độ đọc Webtoon</string>
<string name="saved_filters">Lưu bộ lọc tìm kiếm</string>
<string name="enter_name">Hãy điền tên</string>
<string name="two_page_scroll_sensitivity">Độ nhạy khi cuộn hai trang</string>
<string name="reader_chapter_toast">Hiện thông báo chuyển chương</string>
<string name="reader_chapter_toast_summary">Hiển thị một bong bóng nổi nho nhỏ ở phía dưới màn hình cùng với tên của chương truyện khi bạn đọc sang một chương truyện mới</string>
<string name="rename">Đặt tên</string>
<string name="save_filter">Lưu bộ lọc</string>
<string name="overwrite">Đè lên</string>
<string name="filter_overwrite_confirm">Một bộ lộc có tên là \"%s\" đã tồn tại trước đó. Bạn có muốn ghi đè nó lên không?</string>
<string name="storage_and_network">Lưu trữ và mạng</string>
<string name="create_or_restore_backup">Tạo hoặc khôi phục từ một bản sao lưu</string>
<string name="data_removal">Xoá dữ liệu</string>
<string name="privacy">Quyền riêng tư</string>
<string name="source_broken_warning">Nguồn đọc này đã được gắn là \"Không còn sử dụng được\". Một vài tính năng có thể sẽ không hoạt động</string>
<string name="frequency_every_6_hours">6 tiếng một lần</string>
<string name="download_default_directory">Đường dẫn mặc định để tải truyện về thiết bị</string>
<string name="private_app_directory_warning">Đường dẫn này với tất cả dữ liệu của ứng dụng sẽ bị xóa nếu bạn gỡ ứng dụng Kotatsu</string>
<string name="available_pattern">Hiện có sẵn %1$s</string>
</resources>

View File

@@ -75,6 +75,7 @@
<item>@string/advanced</item>
</string-array>
<string-array name="backup_frequency" translatable="false">
<item>@string/frequency_every_6_hours</item>
<item>@string/frequency_every_day</item>
<item>@string/frequency_every_2_days</item>
<item>@string/frequency_once_per_week</item>

View File

@@ -56,6 +56,7 @@
<item>SOCKS</item>
</string-array>
<string-array name="values_backup_frequency" translatable="false">
<item>0.25</item>
<item>1</item>
<item>2</item>
<item>7</item>

File diff suppressed because it is too large Load Diff

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