Compare commits

...

56 Commits

Author SHA1 Message Date
Koitharu 34f6e5232b
Update readme 6 months ago
Koitharu f205c1b3dc
Merge branch 'devel' of github.com:KotatsuApp/Kotatsu into devel 6 months ago
Milo Ivir 4b2a487c37 Translated using Weblate (Croatian)
Currently translated at 99.8% (895 of 896 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
6 months ago
MuhamadSyabitHidayattulloh 726ac21974 Translated using Weblate (Indonesian)
Currently translated at 99.8% (895 of 896 strings)

Co-authored-by: MuhamadSyabitHidayattulloh <tebepc@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
6 months ago
Robert Broketa 6b35216949 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (896 of 896 strings)

Co-authored-by: Robert Broketa <robert@broketa.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
6 months ago
João Augusto Casagrande 22cae62f17 Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.8% (895 of 896 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
6 months ago
Oğuz Ersen 4733caf2e6 Translated using Weblate (Turkish)
Currently translated at 100.0% (896 of 896 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
6 months ago
Максим Горпиніч d49103de1f Translated using Weblate (Ukrainian)
Currently translated at 100.0% (896 of 896 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (894 of 894 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (893 of 893 strings)

Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
6 months ago
Koitharu 414bab7ce3
Update readme 6 months ago
Koitharu 64c1873eb5
Merge branch 'master' into devel 6 months ago
Koitharu 06a0b5829b
Fix crashes
(cherry picked from commit 1d32f53bdd)
6 months ago
Koitharu 0ce2870c8b
Fix chapters list not accessible
(cherry picked from commit 5701862661)
6 months ago
Koitharu f59027666b
Fix loading empty manga
(cherry picked from commit 5590ab7c8a)
6 months ago
Nathan Bapin 8513bc6daf
Fix forget page when the screen is rotated (#1674)
(cherry picked from commit e2fcfcc7a8)
6 months ago
Koitharu cceaefc896
Avoid memory leak in ExceptionResolver
(cherry picked from commit 7a3b2a9bb4)
6 months ago
Koitharu 1d32f53bdd
Fix crashes 6 months ago
Koitharu 0e98dd8695
Refactor SearchMenuProvider 6 months ago
MuhamadSyabitHidayattulloh 119b7c2ac7 Add filtering options for pinned sources and empty results in search menu 6 months ago
Koitharu 5701862661
Fix chapters list not accessible 6 months ago
Koitharu 5590ab7c8a
Fix loading empty manga 6 months ago
Koitharu 9fde0106be
Fix code formatting 6 months ago
skepsun e73f077dc5 remove unnecessary summary 6 months ago
skepsun c37458d43a Add foldable device support (auto two-page) 6 months ago
Nathan Bapin e2fcfcc7a8
Fix forget page when the screen is rotated (#1674) 6 months ago
Koitharu 7a3b2a9bb4
Avoid memory leak in ExceptionResolver 6 months ago
Koitharu 881f154b5e
Update parsers 6 months ago
Koitharu 34be5d16f2
Merge pull request #1701 from weblate/weblate-kotatsu-strings 6 months ago
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
6 months ago
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
6 months ago
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
6 months ago
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
6 months ago
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
6 months ago
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
6 months ago
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
6 months ago
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
6 months ago
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
6 months ago
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
6 months ago
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
6 months ago
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
6 months ago
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
6 months ago
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
6 months ago
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
6 months ago
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
6 months ago
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
6 months ago
Максим Горпиніч 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
6 months ago
Макар Разин 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
6 months ago
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
6 months ago
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
6 months ago
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.
6 months ago
Koitharu beaf5cc0d5
Remove SavedFilterBackup class 6 months ago
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`.
6 months ago
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.
6 months ago
Koitharu dbada34a43
Move pull gesture option to reader settings 6 months ago
Koitharu b62467964e
Fix filters on tablet 6 months ago
Koitharu 3249e10931
Exclude broken sources from catalog 6 months ago
Koitharu 0d5229b112
Improve local manga directories config screen 6 months ago

@ -1,24 +1,16 @@
<div align="center">
<a href="https://kotatsu.app">
<img src="./.github/assets/vtuber.png" alt="Kotatsu Logo" title="Kotatsu" width="600"/>
</a>
# [Kotatsu](https://kotatsu.app)
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in online content sources.**
> [!IMPORTANT]
> In light of recent challenges — including threating actions from Kakao Entertainment Corp and upcoming Googles
> [new sideloading policy](https://f-droid.org/ru/2025/10/28/sideloading.html) — weve made the difficult decision to shut down Kotatsu and end its support. Were deeply grateful
> to everyone who contributed and to the amazing community that grew around this project.
![Downloads count](https://img.shields.io/github/downloads/KotatsuApp/Kotatsu/total?color=1976d2) ![Latest Stable version](https://img.shields.io/github/v/release/KotatsuApp/Kotatsu?color=2596be&label=latest) ![Android 6.0](https://img.shields.io/badge/android-6.0+-brightgreen) [![Sources count](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FKotatsuApp%2Fkotatsu-parsers%2Frefs%2Fheads%2Fmaster%2F.github%2Fsummary.yaml&query=total&label=manga%20sources&color=%23E9321C)](https://github.com/KotatsuApp/kotatsu-parsers) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF?)](https://t.me/kotatsuapp) [![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu)](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
---
### Download
<div align="left">
<div align="center">
* **Recommended:** Download and install APK from [GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest). Application has a built-in self-updating feature.
* Get it on [F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu). The F-Droid build may be a bit outdated and some fixes might be missing.
* Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (Unstable, use at your own risk). Application has a built-in self-updating feature.
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in
online content sources.**
</div>
![Android 6.0](https://img.shields.io/badge/android-6.0+-brightgreen) [![Sources count](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FKotatsuApp%2Fkotatsu-parsers%2Frefs%2Fheads%2Fmaster%2F.github%2Fsummary.yaml&query=total&label=manga%20sources&color=%23E9321C)](https://github.com/KotatsuApp/kotatsu-parsers) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF?)](https://t.me/kotatsuapp) [![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu)](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
### Main Features
@ -86,7 +78,8 @@ please head over to the [Weblate project page](https://hosted.weblate.org/engage
</br>
**📌 Pull requests are welcome, if you want: See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
**📌 Pull requests are welcome, if you want:
See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
### Certificate fingerprints
@ -104,7 +97,9 @@ please head over to the [Weblate project page](https://hosted.weblate.org/engage
<div align="left">
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions.
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications
to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build &
install instructions.
</div>
@ -112,6 +107,9 @@ You may copy, distribute and modify the software as long as you track changes/da
<div align="left">
The developers of this application do not have any affiliation with the content available in the app and does not store or distribute any content. This application should be considered a web browser, all content that can be found using this application is freely available on the Internet. All DMCA takedown requests should be sent to the owners of the website where the content is hosted.
The developers of this application do not have any affiliation with the content available in the app and does not store
or distribute any content. This application should be considered a web browser, all content that can be found using this
application is freely available on the Internet. All DMCA takedown requests should be sent to the owners of the website
where the content is hosted.
</div>

@ -21,8 +21,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 23
targetSdk = 36
versionCode = 1031
versionName = '9.3'
versionCode = 1033
versionName = '9.4.1'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
@ -155,6 +155,9 @@ dependencies {
implementation libs.androidx.work.runtime
implementation libs.guava
// Foldable/Window layout
implementation libs.androidx.window
implementation libs.androidx.room.runtime
implementation libs.androidx.room.ktx
ksp libs.androidx.room.compiler

@ -34,6 +34,9 @@ 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
@ -45,240 +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
}
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.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.SETTINGS -> output.writeString(
section = BackupSection.SETTINGS,
data = dumpSettings(),
)
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.SOURCES -> output.writeJsonArray(
section = BackupSection.SOURCES,
data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) },
serializer = serializer(),
)
BackupSection.SCROBBLING -> output.writeJsonArray(
section = BackupSection.SCROBBLING,
data = database.getScrobblingDao().dumpEnabled().map { ScrobblingBackup(it) },
serializer = serializer(),
)
BackupSection.STATS -> output.writeJsonArray(
section = BackupSection.STATS,
data = database.getStatsDao().dumpEnabled().map { StatisticBackup(it) },
serializer = serializer(),
)
}
progress?.emit(commonProgress)
commonProgress++
}
progress?.emit(commonProgress)
}
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.CATEGORIES -> input.readJsonArray<CategoryBackup>(serializer()).restoreToDb {
getFavouriteCategoriesDao().upsert(it.toEntity())
}
BackupSection.FAVOURITES -> input.readJsonArray<FavouriteBackup>(serializer()).restoreToDb {
upsertManga(it.manga)
getFavouritesDao().upsert(it.toEntity())
}
BackupSection.SETTINGS -> input.readMap().let {
settings.upsertAll(it)
CompositeResult.success()
}
BackupSection.SETTINGS_READER_GRID -> input.readMap().let {
tapGridSettings.upsertAll(it)
CompositeResult.success()
}
BackupSection.BOOKMARKS -> input.readJsonArray<BookmarkBackup>(serializer()).restoreToDb {
upsertManga(it.manga)
getBookmarksDao().upsert(it.bookmarks.map { b -> b.toEntity() })
}
BackupSection.SOURCES -> input.readJsonArray<SourceBackup>(serializer()).restoreToDb {
getSourcesDao().upsert(it.toEntity())
}
BackupSection.SCROBBLING -> input.readJsonArray<ScrobblingBackup>(serializer()).restoreToDb {
getScrobblingDao().upsert(it.toEntity())
}
BackupSection.STATS -> input.readJsonArray<StatisticBackup>(serializer()).restoreToDb {
getStatsDao().upsert(it.toEntity())
}
null -> CompositeResult.EMPTY // skip unknown entries
}
progress?.emit(commonProgress)
commonProgress++
}
input.closeEntry()
entry = input.nextEntry
}
progress?.emit(commonProgress)
return result
}
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 <T> InputStream.readJsonArray(
serializer: DeserializationStrategy<T>,
): Sequence<T> = json.decodeToSequence(this, serializer, DecodeSequenceMode.ARRAY_WRAPPED)
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 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 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(),
)
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.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_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.SOURCES -> output.writeJsonArray(
section = BackupSection.SOURCES,
data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) },
serializer = serializer(),
)
BackupSection.SCROBBLING -> output.writeJsonArray(
section = BackupSection.SCROBBLING,
data = database.getScrobblingDao().dumpEnabled().map { ScrobblingBackup(it) },
serializer = serializer(),
)
BackupSection.STATS -> output.writeJsonArray(
section = BackupSection.STATS,
data = database.getStatsDao().dumpEnabled().map { StatisticBackup(it) },
serializer = serializer(),
)
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)
}
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.CATEGORIES -> input.readJsonArray<CategoryBackup>(serializer()).restoreToDb {
getFavouriteCategoriesDao().upsert(it.toEntity())
}
BackupSection.FAVOURITES -> input.readJsonArray<FavouriteBackup>(serializer()).restoreToDb {
upsertManga(it.manga)
getFavouritesDao().upsert(it.toEntity())
}
BackupSection.SETTINGS -> input.readMap().let {
settings.upsertAll(it)
CompositeResult.success()
}
BackupSection.SETTINGS_READER_GRID -> input.readMap().let {
tapGridSettings.upsertAll(it)
CompositeResult.success()
}
BackupSection.BOOKMARKS -> input.readJsonArray<BookmarkBackup>(serializer()).restoreToDb {
upsertManga(it.manga)
getBookmarksDao().upsert(it.bookmarks.map { b -> b.toEntity() })
}
BackupSection.SOURCES -> input.readJsonArray<SourceBackup>(serializer()).restoreToDb {
getSourcesDao().upsert(it.toEntity())
}
BackupSection.SCROBBLING -> input.readJsonArray<ScrobblingBackup>(serializer()).restoreToDb {
getScrobblingDao().upsert(it.toEntity())
}
BackupSection.STATS -> input.readJsonArray<StatisticBackup>(serializer()).restoreToDb {
getStatsDao().upsert(it.toEntity())
}
BackupSection.SAVED_FILTERS -> input.readJsonArray<PersistableFilter>(serializer())
.restoreWithoutTransaction {
savedFiltersRepository.save(it)
}
null -> CompositeResult.EMPTY // skip unknown entries
}
progress?.emit(commonProgress)
commonProgress++
}
input.closeEntry()
entry = input.nextEntry
}
progress?.emit(commonProgress)
return result
}
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 <T> InputStream.readJsonArray(
serializer: DeserializationStrategy<T>,
): Sequence<T> = json.decodeToSequence(this, serializer, DecodeSequenceMode.ARRAY_WRAPPED)
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 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)
}
}
}
}

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

@ -17,6 +17,7 @@ enum class BackupSection(
SOURCES("sources"),
SCROBBLING("scrobbling"),
STATS("statistics"),
SAVED_FILTERS("saved_filters"),
;
companion object {

@ -25,6 +25,7 @@ data class BackupSectionModel(
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 {

@ -0,0 +1,10 @@
package org.koitharu.kotatsu.core.exceptions
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
import org.koitharu.kotatsu.parsers.model.Manga
class EmptyMangaException(
val reason: EmptyMangaReason?,
val manga: Manga,
cause: Throwable?
) : IllegalStateException(cause)

@ -8,13 +8,15 @@ import androidx.collection.MutableScatterMap
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.async
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
@ -24,6 +26,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
import org.koitharu.kotatsu.core.util.ext.restartApplication
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
@ -32,164 +35,221 @@ import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredExcept
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import java.security.cert.CertPathValidatorException
import javax.inject.Inject
import javax.inject.Provider
import javax.net.ssl.SSLException
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class ExceptionResolver @AssistedInject constructor(
@Assisted private val host: Host,
private val settings: AppSettings,
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
class ExceptionResolver private constructor(
private val host: Host,
private val settings: AppSettings,
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
) {
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
private val 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)
}
suspend fun resolve(e: Throwable): Boolean = when (e) {
is CloudFlareProtectedException -> resolveCF(e)
is AuthRequiredException -> resolveAuthException(e.source)
is SSLException,
is CertPathValidatorException -> {
showSslErrorDialog()
false
}
is InteractiveActionRequiredException -> resolveBrowserAction(e)
is ProxyConfigException -> {
host.router()?.openProxySettings()
false
}
is NotFoundException -> {
openInBrowser(e.url)
false
}
is UnsupportedSourceException -> {
e.manga?.let { openAlternatives(it) }
false
}
is ScrobblerAuthRequiredException -> {
val authHelper = scrobblerAuthHelperProvider.get()
if (authHelper.isAuthorized(e.scrobbler)) {
true
} else {
host.withContext {
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
}
false
}
}
else -> false
}
private suspend fun resolveBrowserAction(
e: InteractiveActionRequiredException
): Boolean = suspendCoroutine { cont ->
continuations[BrowserActivity.TAG] = cont
browserActionContract.launch(e)
}
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
continuations[CloudFlareActivity.TAG] = cont
cloudflareContract.launch(e)
}
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
continuations[SourceAuthActivity.TAG] = cont
sourceAuthContract.launch(source)
}
private fun openInBrowser(url: String) {
host.router()?.openBrowser(url, null, null)
}
private fun openAlternatives(manga: Manga) {
host.router()?.openAlternatives(manga)
}
private fun handleActivityResult(tag: String, result: Boolean) {
continuations.remove(tag)?.resume(result)
}
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 inline fun Host.withContext(block: Context.() -> Unit) {
getContext()?.apply(block)
}
private fun Host.router(): AppRouter? = when (this) {
is FragmentActivity -> router
is Fragment -> router
else -> null
}
interface Host : ActivityResultCaller {
fun getChildFragmentManager(): FragmentManager
fun getContext(): Context?
}
@AssistedFactory
interface Factory {
fun create(host: Host): ExceptionResolver
}
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
else -> 0
}
fun canResolve(e: Throwable) = getResolveStringId(e) != 0
}
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)
}
fun showErrorDetails(e: Throwable, url: String? = null) {
host.router.showErrorDialog(e, url)
}
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 ProxyConfigException -> {
host.router.openProxySettings()
false
}
is NotFoundException -> {
openInBrowser(e.url)
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 UnsupportedSourceException -> {
e.manga?.let { openAlternatives(it) }
false
}
is ScrobblerAuthRequiredException -> {
val authHelper = scrobblerAuthHelperProvider.get()
if (authHelper.isAuthorized(e.scrobbler)) {
true
} else {
host.withContext {
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
}
false
}
}
else -> false
}
}.await()
private suspend fun resolveBrowserAction(
e: InteractiveActionRequiredException
): Boolean = suspendCoroutine { cont ->
continuations[BrowserActivity.TAG] = cont
browserActionContract.launch(e)
}
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
continuations[CloudFlareActivity.TAG] = cont
cloudflareContract.launch(e)
}
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
continuations[SourceAuthActivity.TAG] = cont
sourceAuthContract.launch(source)
}
private fun openInBrowser(url: String) {
host.router.openBrowser(url, null, null)
}
private fun openAlternatives(manga: Manga) {
host.router.openAlternatives(manga)
}
private fun handleActivityResult(tag: String, result: Boolean) {
continuations.remove(tag)?.resume(result)
}
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()
}
class Factory @Inject constructor(
private val settings: AppSettings,
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
) {
fun create(fragment: Fragment) = ExceptionResolver(
host = Host.FragmentHost(fragment),
settings = settings,
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
)
fun create(activity: FragmentActivity) = ExceptionResolver(
host = Host.ActivityHost(activity),
settings = settings,
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
)
}
private sealed interface Host : ActivityResultCaller, LifecycleOwner {
val context: Context?
val router: AppRouter
val fragmentManager: FragmentManager
inline fun withContext(block: Context.() -> Unit) {
context?.apply(block)
}
class ActivityHost(val activity: FragmentActivity) : Host,
ActivityResultCaller by activity,
LifecycleOwner by activity {
override val context: Context
get() = activity
override val router: AppRouter
get() = activity.router
override val fragmentManager: FragmentManager
get() = activity.supportFragmentManager
}
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
}
}

@ -138,6 +138,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
var isReaderDoubleOnFoldable: Boolean
get() = prefs.getBoolean(KEY_READER_DOUBLE_FOLDABLE, false)
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_FOLDABLE, value) }
@get:FloatRange(0.0, 1.0)
var readerDoublePagesSensitivity: Float
get() = prefs.getFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, 0.5f)
@ -546,11 +550,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isPeriodicalBackupEnabled: Boolean
get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false)
val periodicalBackupFrequency: Long
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L
val periodicalBackupFrequency: Float
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toFloatOrNull() ?: 7f
val periodicalBackupFrequencyMillis: Long
get() = TimeUnit.DAYS.toMillis(periodicalBackupFrequency)
get() = (TimeUnit.DAYS.toMillis(1) * periodicalBackupFrequency).toLong()
val periodicalBackupMaxCount: Int
get() = if (prefs.getBoolean(KEY_BACKUP_PERIODICAL_TRIM, true)) {
@ -681,7 +685,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_REMOTE_SOURCES = "remote_sources"
const val KEY_LOCAL_STORAGE = "local_storage"
const val KEY_READER_DOUBLE_PAGES = "reader_double_pages"
const val KEY_READER_DOUBLE_PAGES_SENSITIVITY = "reader_double_pages_sensitivity"
const val KEY_READER_DOUBLE_PAGES_SENSITIVITY = "reader_double_pages_sensitivity_2"
const val KEY_READER_DOUBLE_FOLDABLE = "reader_double_foldable"
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
const val KEY_READER_NAVIGATION_INVERTED = "reader_navigation_inverted"

@ -33,7 +33,6 @@ import androidx.appcompat.R as appcompatR
abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(),
ExceptionResolver.Host,
OnApplyWindowInsetsListener,
ScreenshotPolicyHelper.ContentContainer {
@ -87,10 +86,6 @@ abstract class BaseActivity<B : ViewBinding> :
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
override fun setContentView(view: View?) = throw UnsupportedOperationException()
override fun getContext() = this
override fun getChildFragmentManager(): FragmentManager = supportFragmentManager
protected fun setContentView(binding: B) {
this.viewBinding = binding
super.setContentView(binding.root)

@ -15,8 +15,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
abstract class BaseFragment<B : ViewBinding> :
OnApplyWindowInsetsListener,
Fragment(),
ExceptionResolver.Host {
Fragment() {
var viewBinding: B? = null
private set

@ -36,8 +36,7 @@ import com.google.android.material.R as materialR
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(),
OnApplyWindowInsetsListener,
RecyclerViewOwner,
ExceptionResolver.Host {
RecyclerViewOwner {
protected lateinit var exceptionResolver: ExceptionResolver
private set

@ -5,7 +5,10 @@ import android.view.View
import androidx.annotation.Px
import androidx.recyclerview.widget.RecyclerView
class SpacingItemDecoration(@Px private val spacing: Int) : RecyclerView.ItemDecoration() {
class SpacingItemDecoration(
@Px private val spacing: Int,
private val withBottomPadding: Boolean,
) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
@ -13,6 +16,6 @@ class SpacingItemDecoration(@Px private val spacing: Int) : RecyclerView.ItemDec
parent: RecyclerView,
state: RecyclerView.State,
) {
outRect.set(spacing, spacing, spacing, spacing)
outRect.set(spacing, spacing, spacing, if (withBottomPadding) spacing else 0)
}
}

@ -32,8 +32,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import com.google.android.material.R as materialR
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(),
OnApplyWindowInsetsListener,
ExceptionResolver.Host {
OnApplyWindowInsetsListener {
private var waitingForDismissAllowingStateLoss = false
private var isFitToContentsDisabled = false

@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.exceptions.CaughtException
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
@ -62,216 +63,219 @@ private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
private val FNFE_MESSAGE_REGEX = Regex("^(/[^\\s:]+)?.+?\\s([A-Z]{2,6})?\\s.+$")
fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessageOrNull(resources)
?: resources.getString(R.string.error_occurred)
?: resources.getString(R.string.error_occurred)
private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) {
is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message
is CaughtException -> cause.getDisplayMessageOrNull(resources)
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
is ScrobblerAuthRequiredException -> resources.getString(
R.string.scrobbler_auth_required,
resources.getString(scrobbler.titleResId),
)
is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message
is CaughtException -> cause.getDisplayMessageOrNull(resources)
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
is ScrobblerAuthRequiredException -> resources.getString(
R.string.scrobbler_auth_required,
resources.getString(scrobbler.titleResId),
)
is AuthRequiredException -> resources.getString(R.string.auth_required)
is InteractiveActionRequiredException -> resources.getString(R.string.additional_action_required)
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message)
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
is ActivityNotFoundException,
is UnsupportedOperationException,
-> resources.getString(R.string.operation_not_supported)
is AuthRequiredException -> resources.getString(R.string.auth_required)
is InteractiveActionRequiredException -> resources.getString(R.string.additional_action_required)
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message)
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
is ActivityNotFoundException,
is UnsupportedOperationException,
-> resources.getString(R.string.operation_not_supported)
is TooManyRequestExceptions -> {
val delay = getRetryDelay()
val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) {
resources.formatDurationShort(delay)
} else {
null
}
if (formattedTime != null) {
resources.getString(R.string.too_many_requests_message_retry, formattedTime)
} else {
resources.getString(R.string.too_many_requests_message)
}
}
is TooManyRequestExceptions -> {
val delay = getRetryDelay()
val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) {
resources.formatDurationShort(delay)
} else {
null
}
if (formattedTime != null) {
resources.getString(R.string.too_many_requests_message_retry, formattedTime)
} else {
resources.getString(R.string.too_many_requests_message)
}
}
is ZipException -> resources.getString(R.string.error_corrupted_zip, this.message.orEmpty())
is SQLiteFullException -> resources.getString(R.string.error_no_space_left)
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
is FileNotFoundException -> parseMessage(resources) ?: message
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
is SyncApiException,
is ContentUnavailableException -> message
is ZipException -> resources.getString(R.string.error_corrupted_zip, this.message.orEmpty())
is SQLiteFullException -> resources.getString(R.string.error_no_space_left)
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
is FileNotFoundException -> parseMessage(resources) ?: message
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
is EmptyMangaException -> reason?.let { resources.getString(it.msgResId) } ?: cause?.getDisplayMessage(resources)
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
is SyncApiException,
is ContentUnavailableException -> message
is ParseException -> shortMessage
is ConnectException,
is UnknownHostException,
is NoRouteToHostException,
is SocketTimeoutException -> resources.getString(R.string.network_error)
is ParseException -> shortMessage
is ConnectException,
is UnknownHostException,
is NoRouteToHostException,
is SocketTimeoutException -> resources.getString(R.string.network_error)
is ImageDecodeException -> {
val type = format?.substringBefore('/')
val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) }
if (type.isNullOrEmpty() || type == "image") {
resources.getString(R.string.error_image_format, formatString)
} else {
resources.getString(R.string.error_not_image, formatString)
}
}
is ImageDecodeException -> {
val type = format?.substringBefore('/')
val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) }
if (type.isNullOrEmpty() || type == "image") {
resources.getString(R.string.error_image_format, formatString)
} else {
resources.getString(R.string.error_not_image, formatString)
}
}
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
is IncompatiblePluginException -> {
cause?.getDisplayMessageOrNull(resources)?.let {
resources.getString(R.string.plugin_incompatible_with_cause, it)
} ?: resources.getString(R.string.plugin_incompatible)
}
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
is IncompatiblePluginException -> {
cause?.getDisplayMessageOrNull(resources)?.let {
resources.getString(R.string.plugin_incompatible_with_cause, it)
} ?: resources.getString(R.string.plugin_incompatible)
}
is WrongPasswordException -> resources.getString(R.string.wrong_password)
is NotFoundException -> resources.getString(R.string.not_found_404)
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
is WrongPasswordException -> resources.getString(R.string.wrong_password)
is NotFoundException -> resources.getString(R.string.not_found_404)
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
is HttpException -> getHttpDisplayMessage(response.code, resources)
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
is HttpException -> getHttpDisplayMessage(response.code, resources)
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
else -> mapDisplayMessage(message, resources) ?: message
else -> mapDisplayMessage(message, resources) ?: message
}.takeUnless { it.isNullOrBlank() }
@DrawableRes
fun Throwable.getDisplayIcon(): Int = when (this) {
is AuthRequiredException -> R.drawable.ic_auth_key_large
is CloudFlareProtectedException -> R.drawable.ic_bot_large
is UnknownHostException,
is SocketTimeoutException,
is ConnectException,
is NoRouteToHostException,
is ProtocolException -> R.drawable.ic_plug_large
is AuthRequiredException -> R.drawable.ic_auth_key_large
is CloudFlareProtectedException -> R.drawable.ic_bot_large
is UnknownHostException,
is SocketTimeoutException,
is ConnectException,
is NoRouteToHostException,
is ProtocolException -> R.drawable.ic_plug_large
is CloudFlareBlockedException -> R.drawable.ic_denied_large
is CloudFlareBlockedException -> R.drawable.ic_denied_large
is InteractiveActionRequiredException -> R.drawable.ic_interaction_large
else -> R.drawable.ic_error_large
is InteractiveActionRequiredException -> R.drawable.ic_interaction_large
else -> R.drawable.ic_error_large
}
fun Throwable.getCauseUrl(): String? = when (this) {
is ParseException -> url
is NotFoundException -> url
is TooManyRequestExceptions -> url
is CaughtException -> cause.getCauseUrl()
is WrapperIOException -> cause.getCauseUrl()
is NoDataReceivedException -> url
is CloudFlareBlockedException -> url
is CloudFlareProtectedException -> url
is InteractiveActionRequiredException -> url
is HttpStatusException -> url
is HttpException -> (response.delegate as? Response)?.request?.url?.toString()
else -> null
is ParseException -> url
is NotFoundException -> url
is TooManyRequestExceptions -> url
is CaughtException -> cause.getCauseUrl()
is WrapperIOException -> cause.getCauseUrl()
is NoDataReceivedException -> url
is CloudFlareBlockedException -> url
is CloudFlareProtectedException -> url
is InteractiveActionRequiredException -> url
is HttpStatusException -> url
is UnsupportedSourceException -> manga?.publicUrl?.takeIf { it.isHttpUrl() }
is EmptyMangaException -> manga.publicUrl.takeIf { it.isHttpUrl() }
is HttpException -> (response.delegate as? Response)?.request?.url?.toString()
else -> null
}
private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) {
HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404)
HttpURLConnection.HTTP_FORBIDDEN -> resources.getString(R.string.access_denied_403)
HttpURLConnection.HTTP_GATEWAY_TIMEOUT -> resources.getString(R.string.network_unavailable)
in 500..599 -> resources.getString(R.string.server_error, statusCode)
else -> null
HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404)
HttpURLConnection.HTTP_FORBIDDEN -> resources.getString(R.string.access_denied_403)
HttpURLConnection.HTTP_GATEWAY_TIMEOUT -> resources.getString(R.string.network_unavailable)
in 500..599 -> resources.getString(R.string.server_error, statusCode)
else -> null
}
private fun mapDisplayMessage(msg: String?, resources: Resources): String? = when {
msg.isNullOrEmpty() -> null
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
msg == MSG_CONNECTION_RESET -> resources.getString(R.string.error_connection_reset)
msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported)
msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported)
else -> null
msg.isNullOrEmpty() -> null
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
msg == MSG_CONNECTION_RESET -> resources.getString(R.string.error_connection_reset)
msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported)
msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported)
else -> null
}
fun Throwable.isReportable(): Boolean {
if (this is Error) {
return true
}
if (this is CaughtException) {
return cause.isReportable()
}
if (this is WrapperIOException) {
return cause.isReportable()
}
if (ExceptionResolver.canResolve(this)) {
return false
}
if (this is ParseException
|| this.isNetworkError()
|| this is CloudFlareBlockedException
|| this is CloudFlareProtectedException
|| this is BadBackupFormatException
|| this is WrongPasswordException
|| this is TooManyRequestExceptions
|| this is HttpStatusException
) {
return false
}
return true
if (this is Error) {
return true
}
if (this is CaughtException) {
return cause.isReportable()
}
if (this is WrapperIOException) {
return cause.isReportable()
}
if (ExceptionResolver.canResolve(this)) {
return false
}
if (this is ParseException
|| this.isNetworkError()
|| this is CloudFlareBlockedException
|| this is CloudFlareProtectedException
|| this is BadBackupFormatException
|| this is WrongPasswordException
|| this is TooManyRequestExceptions
|| this is HttpStatusException
) {
return false
}
return true
}
fun Throwable.isNetworkError(): Boolean {
return this is UnknownHostException
|| this is SocketTimeoutException
|| this is StreamResetException
|| this is SocketException
|| this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT
return this is UnknownHostException
|| this is SocketTimeoutException
|| this is StreamResetException
|| this is SocketException
|| this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT
}
fun Throwable.report(silent: Boolean = false) {
val exception = CaughtException(this)
if (!silent) {
exception.sendWithAcra()
} else if (!BuildConfig.DEBUG) {
exception.sendSilentlyWithAcra()
}
val exception = CaughtException(this)
if (!silent) {
exception.sendWithAcra()
} else if (!BuildConfig.DEBUG) {
exception.sendSilentlyWithAcra()
}
}
fun Throwable.isWebViewUnavailable(): Boolean {
val trace = stackTraceToString()
return trace.contains("android.webkit.WebView.<init>")
val trace = stackTraceToString()
return trace.contains("android.webkit.WebView.<init>")
}
@Suppress("FunctionName")
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)
fun FileNotFoundException.getFile(): File? {
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
return groups.getOrNull(1)?.let { File(it) }
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
return groups.getOrNull(1)?.let { File(it) }
}
fun FileNotFoundException.parseMessage(resources: Resources): String? {
/*
Examples:
/storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system)
/storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory)
/storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error)
*/
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
val path = groups.getOrNull(1)
val error = groups.getOrNull(2)
val baseMessageIs = when (error) {
"EROFS" -> R.string.no_write_permission_to_file
"ENOENT" -> R.string.file_not_found
else -> return null
}
return if (path.isNullOrEmpty()) {
resources.getString(baseMessageIs)
} else {
resources.getString(
R.string.inline_preference_pattern,
resources.getString(baseMessageIs),
path,
)
}
/*
Examples:
/storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system)
/storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory)
/storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error)
*/
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
val path = groups.getOrNull(1)
val error = groups.getOrNull(2)
val baseMessageIs = when (error) {
"EROFS" -> R.string.no_write_permission_to_file
"ENOENT" -> R.string.file_not_found
else -> return null
}
return if (path.isNullOrEmpty()) {
resources.getString(baseMessageIs)
} else {
resources.getString(
R.string.inline_preference_pattern,
resources.getString(baseMessageIs),
path,
)
}
}

@ -7,111 +7,115 @@ import org.koitharu.kotatsu.core.ui.model.MangaOverride
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.reader.data.filterChapters
import java.util.Locale
data class MangaDetails(
private val manga: Manga,
private val localManga: LocalManga?,
private val override: MangaOverride?,
val description: CharSequence?,
val isLoaded: Boolean,
private val manga: Manga,
private val localManga: LocalManga?,
private val override: MangaOverride?,
val description: CharSequence?,
val isLoaded: Boolean,
) {
constructor(manga: Manga) : this(
manga = manga,
localManga = null,
override = null,
description = null,
isLoaded = false,
)
val id: Long
get() = manga.id
val allChapters: List<MangaChapter> by lazy { mergeChapters() }
val chapters: Map<String?, List<MangaChapter>> by lazy {
allChapters.groupBy { it.branch }
}
val isLocal
get() = manga.isLocal
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()
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 toManga() = mergedManga
fun getLocale(): Locale? {
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
return it
}
return manga.source.getLocale()
}
fun filterChapters(branch: String?) = copy(
manga = manga.filterChapters(branch),
localManga = localManga?.run {
copy(manga = manga.filterChapters(branch))
},
)
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)
}
}
constructor(manga: Manga) : this(
manga = manga,
localManga = null,
override = null,
description = null,
isLoaded = false,
)
val id: Long
get() = manga.id
val allChapters: List<MangaChapter> by lazy { mergeChapters() }
val chapters: Map<String?, List<MangaChapter>> by lazy {
allChapters.groupBy { it.branch }
}
val isLocal
get() = manga.isLocal
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 isRestricted: Boolean
get() = manga.state == MangaState.RESTRICTED
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 toManga() = mergedManga
fun getLocale(): Locale? {
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
return it
}
return manga.source.getLocale()
}
fun filterChapters(branch: String?) = copy(
manga = manga.filterChapters(branch),
localManga = localManga?.run {
copy(manga = manga.filterChapters(branch))
},
)
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)
}
}
}

@ -100,7 +100,7 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(),
override fun onStateChanged(sheet: View, newState: Int) {
val binding = viewBinding ?: return
binding.layoutTouchBlock.isTouchEventsAllowed = newState != STATE_COLLAPSED
binding.layoutTouchBlock.isTouchEventsAllowed = dialog != null || newState != STATE_COLLAPSED
if (newState == STATE_DRAGGING || newState == STATE_SETTLING) {
return
}

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

@ -57,10 +57,16 @@ class SavedFiltersRepository @Inject constructor(
source = source,
filter = filter,
)
persist(source, persistableFilter)
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)
@ -79,8 +85,8 @@ class SavedFiltersRepository @Inject constructor(
}
}
private fun persist(source: MangaSource, persistableFilter: PersistableFilter) {
val prefs = getPrefs(source)
private fun persist(persistableFilter: PersistableFilter) {
val prefs = getPrefs(persistableFilter.source)
val json = Json.encodeToString(persistableFilter)
prefs.edit(commit = true) {
putString(key(persistableFilter.id), json)

@ -2,16 +2,19 @@ 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
@ -69,8 +72,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
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
binding.adjustForEmbeddedLayout()
}
val filter = FilterCoordinator.require(this)
filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
@ -127,6 +129,18 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
binding.buttonDone.setOnClickListener(this)
}
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 onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val typeMask = WindowInsetsCompat.Type.systemBars()
viewBinding?.layoutBottom?.updateLayoutParams<ViewGroup.MarginLayoutParams> {

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

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

@ -49,7 +49,7 @@ class ReaderManager(
fun setDoubleReaderMode(isEnabled: Boolean) {
val mode = currentMode
val prevReader = currentReader?.javaClass
invalidateTypesMap(isEnabled && isLandscape())
invalidateTypesMap(isEnabled)
val newReader = modeMap[mode]
if (mode != null && newReader != prevReader) {
replace(mode)

@ -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
@ -37,227 +38,244 @@ import javax.inject.Inject
@AndroidEntryPoint
class ReaderConfigSheet :
BaseAdaptiveSheet<SheetReaderConfigBinding>(),
View.OnClickListener,
MaterialButtonToggleGroup.OnButtonCheckedListener,
CompoundButton.OnCheckedChangeListener,
Slider.OnChangeListener {
private val viewModel by activityViewModels<ReaderViewModel>()
@Inject
lateinit var orientationHelper: ScreenOrientationHelper
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
@Inject
lateinit var pageLoader: PageLoader
private lateinit var mode: ReaderMode
private lateinit var imageServerDelegate: ImageServerDelegate
@Inject
lateinit var settings: AppSettings
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mode = arguments?.getInt(AppRouter.KEY_READER_MODE)
?.let { ReaderMode.valueOf(it) }
?: ReaderMode.STANDARD
imageServerDelegate = ImageServerDelegate(
mangaRepositoryFactory = mangaRepositoryFactory,
mangaSource = viewModel.getMangaOrNull()?.source,
)
}
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
): SheetReaderConfigBinding {
return SheetReaderConfigBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(
binding: SheetReaderConfigBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState)
observeScreenOrientation()
binding.buttonStandard.isChecked = mode == ReaderMode.STANDARD
binding.buttonReversed.isChecked = mode == ReaderMode.REVERSED
binding.buttonWebtoon.isChecked = mode == ReaderMode.WEBTOON
binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL
binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape
binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED
binding.switchPullGesture.isChecked = settings.isWebtoonPullGestureEnabled
binding.switchPullGesture.isEnabled = mode == ReaderMode.WEBTOON
binding.textSensitivity.isVisible = settings.isReaderDoubleOnLandscape
binding.seekbarSensitivity.isVisible = settings.isReaderDoubleOnLandscape
binding.seekbarSensitivity.setValueRounded(settings.readerDoublePagesSensitivity * 100f)
binding.seekbarSensitivity.setLabelFormatter(IntPercentLabelFormatter(binding.root.context))
binding.checkableGroup.addOnButtonCheckedListener(this)
binding.buttonSavePage.setOnClickListener(this)
binding.buttonScreenRotate.setOnClickListener(this)
binding.buttonSettings.setOnClickListener(this)
binding.buttonImageServer.setOnClickListener(this)
binding.buttonColorFilter.setOnClickListener(this)
binding.buttonScrollTimer.setOnClickListener(this)
binding.buttonBookmark.setOnClickListener(this)
binding.switchDoubleReader.setOnCheckedChangeListener(this)
binding.switchPullGesture.setOnCheckedChangeListener(this)
binding.seekbarSensitivity.addOnChangeListener(this)
viewModel.isBookmarkAdded.observe(viewLifecycleOwner) {
binding.buttonBookmark.setText(if (it) R.string.bookmark_remove else R.string.bookmark_add)
binding.buttonBookmark.setCompoundDrawablesRelativeWithIntrinsicBounds(
if (it) R.drawable.ic_bookmark_checked else R.drawable.ic_bookmark, 0, 0, 0,
)
}
viewLifecycleScope.launch {
val isAvailable = imageServerDelegate.isAvailable()
if (isAvailable) {
bindImageServerTitle()
}
binding.buttonImageServer.isVisible = isAvailable
}
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val typeMask = WindowInsetsCompat.Type.systemBars()
viewBinding?.scrollView?.updatePadding(
bottom = insets.getInsets(typeMask).bottom,
)
return insets.consume(v, typeMask, bottom = true)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_settings -> {
router.openReaderSettings()
dismissAllowingStateLoss()
}
R.id.button_scroll_timer -> {
findParentCallback(Callback::class.java)?.onScrollTimerClick(false) ?: return
dismissAllowingStateLoss()
}
R.id.button_save_page -> {
findParentCallback(Callback::class.java)?.onSavePageClick() ?: return
dismissAllowingStateLoss()
}
R.id.button_screen_rotate -> {
orientationHelper.isLandscape = !orientationHelper.isLandscape
}
R.id.button_bookmark -> {
viewModel.toggleBookmark()
}
R.id.button_color_filter -> {
val page = viewModel.getCurrentPage() ?: return
val manga = viewModel.getMangaOrNull() ?: return
router.openColorFilterConfig(manga, page)
}
R.id.button_image_server -> viewLifecycleScope.launch {
if (imageServerDelegate.showDialog(v.context)) {
bindImageServerTitle()
pageLoader.invalidate(clearCache = true)
viewModel.switchChapterBy(0)
}
}
}
}
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
when (buttonView.id) {
R.id.switch_screen_lock_rotation -> {
orientationHelper.isLocked = isChecked
}
R.id.switch_double_reader -> {
settings.isReaderDoubleOnLandscape = isChecked
viewBinding?.textSensitivity?.isVisible = isChecked
viewBinding?.seekbarSensitivity?.isVisible = isChecked
findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked)
}
R.id.switch_pull_gesture -> {
settings.isWebtoonPullGestureEnabled = isChecked
}
}
}
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
settings.readerDoublePagesSensitivity = value / 100f
}
override fun onButtonChecked(
group: MaterialButtonToggleGroup?,
checkedId: Int,
isChecked: Boolean,
) {
if (!isChecked) {
return
}
val newMode = when (checkedId) {
R.id.button_standard -> ReaderMode.STANDARD
R.id.button_webtoon -> ReaderMode.WEBTOON
R.id.button_reversed -> ReaderMode.REVERSED
R.id.button_vertical -> ReaderMode.VERTICAL
else -> return
}
viewBinding?.switchDoubleReader?.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED
viewBinding?.switchPullGesture?.isEnabled = newMode == ReaderMode.WEBTOON
if (newMode == mode) {
return
}
findParentCallback(Callback::class.java)?.onReaderModeChanged(newMode) ?: return
mode = newMode
}
private fun observeScreenOrientation() {
orientationHelper.observeAutoOrientation()
.onEach {
with(requireViewBinding()) {
buttonScreenRotate.isGone = it
switchScreenLockRotation.isVisible = it
updateOrientationLockSwitch()
}
}.launchIn(viewLifecycleScope)
}
private fun updateOrientationLockSwitch() {
val switch = viewBinding?.switchScreenLockRotation ?: return
switch.setOnCheckedChangeListener(null)
switch.isChecked = orientationHelper.isLocked
switch.setOnCheckedChangeListener(this)
}
private suspend fun bindImageServerTitle() {
viewBinding?.buttonImageServer?.text = getString(
R.string.inline_preference_pattern,
getString(R.string.image_server),
imageServerDelegate.getValue() ?: getString(R.string.automatic),
)
}
interface Callback {
fun onReaderModeChanged(mode: ReaderMode)
fun onDoubleModeChanged(isEnabled: Boolean)
fun onSavePageClick()
fun onScrollTimerClick(isLongClick: Boolean)
fun onBookmarkClick()
}
BaseAdaptiveSheet<SheetReaderConfigBinding>(),
View.OnClickListener,
MaterialButtonToggleGroup.OnButtonCheckedListener,
CompoundButton.OnCheckedChangeListener,
Slider.OnChangeListener {
private val viewModel by activityViewModels<ReaderViewModel>()
@Inject
lateinit var orientationHelper: ScreenOrientationHelper
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
@Inject
lateinit var pageLoader: PageLoader
private lateinit var mode: ReaderMode
private lateinit var imageServerDelegate: ImageServerDelegate
@Inject
lateinit var settings: AppSettings
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mode = arguments?.getInt(AppRouter.KEY_READER_MODE)
?.let { ReaderMode.valueOf(it) }
?: ReaderMode.STANDARD
imageServerDelegate = ImageServerDelegate(
mangaRepositoryFactory = mangaRepositoryFactory,
mangaSource = viewModel.getMangaOrNull()?.source,
)
}
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
): SheetReaderConfigBinding {
return SheetReaderConfigBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(
binding: SheetReaderConfigBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState)
observeScreenOrientation()
binding.buttonStandard.isChecked = mode == ReaderMode.STANDARD
binding.buttonReversed.isChecked = mode == ReaderMode.REVERSED
binding.buttonWebtoon.isChecked = mode == ReaderMode.WEBTOON
binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL
binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape
binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED
binding.switchDoubleFoldable.isChecked = settings.isReaderDoubleOnFoldable
binding.switchDoubleFoldable.isEnabled = binding.switchDoubleReader.isEnabled
binding.sliderDoubleSensitivity.setValueRounded(settings.readerDoublePagesSensitivity * 100f)
binding.sliderDoubleSensitivity.setLabelFormatter(IntPercentLabelFormatter(binding.root.context))
binding.adjustSensitivitySlider(withAnimation = false)
binding.checkableGroup.addOnButtonCheckedListener(this)
binding.buttonSavePage.setOnClickListener(this)
binding.buttonScreenRotate.setOnClickListener(this)
binding.buttonSettings.setOnClickListener(this)
binding.buttonImageServer.setOnClickListener(this)
binding.buttonColorFilter.setOnClickListener(this)
binding.buttonScrollTimer.setOnClickListener(this)
binding.buttonBookmark.setOnClickListener(this)
binding.switchDoubleReader.setOnCheckedChangeListener(this)
binding.switchDoubleFoldable.setOnCheckedChangeListener(this)
binding.sliderDoubleSensitivity.addOnChangeListener(this)
viewModel.isBookmarkAdded.observe(viewLifecycleOwner) {
binding.buttonBookmark.setText(if (it) R.string.bookmark_remove else R.string.bookmark_add)
binding.buttonBookmark.setCompoundDrawablesRelativeWithIntrinsicBounds(
if (it) R.drawable.ic_bookmark_checked else R.drawable.ic_bookmark, 0, 0, 0,
)
}
viewLifecycleScope.launch {
val isAvailable = imageServerDelegate.isAvailable()
if (isAvailable) {
bindImageServerTitle()
}
binding.buttonImageServer.isVisible = isAvailable
}
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val typeMask = WindowInsetsCompat.Type.systemBars()
viewBinding?.scrollView?.updatePadding(
bottom = insets.getInsets(typeMask).bottom,
)
return insets.consume(v, typeMask, bottom = true)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_settings -> {
router.openReaderSettings()
dismissAllowingStateLoss()
}
R.id.button_scroll_timer -> {
findParentCallback(Callback::class.java)?.onScrollTimerClick(false) ?: return
dismissAllowingStateLoss()
}
R.id.button_save_page -> {
findParentCallback(Callback::class.java)?.onSavePageClick() ?: return
dismissAllowingStateLoss()
}
R.id.button_screen_rotate -> {
orientationHelper.isLandscape = !orientationHelper.isLandscape
}
R.id.button_bookmark -> {
viewModel.toggleBookmark()
}
R.id.button_color_filter -> {
val page = viewModel.getCurrentPage() ?: return
val manga = viewModel.getMangaOrNull() ?: return
router.openColorFilterConfig(manga, page)
}
R.id.button_image_server -> viewLifecycleScope.launch {
if (imageServerDelegate.showDialog(v.context)) {
bindImageServerTitle()
pageLoader.invalidate(clearCache = true)
viewModel.switchChapterBy(0)
}
}
}
}
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
when (buttonView.id) {
R.id.switch_screen_lock_rotation -> {
orientationHelper.isLocked = isChecked
}
R.id.switch_double_reader -> {
settings.isReaderDoubleOnLandscape = isChecked
viewBinding?.adjustSensitivitySlider(withAnimation = true)
findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked)
}
R.id.switch_double_foldable -> {
settings.isReaderDoubleOnFoldable = isChecked
// Re-evaluate double-page considering foldable state and current manual toggle
findParentCallback(Callback::class.java)?.onDoubleModeChanged(settings.isReaderDoubleOnLandscape)
}
}
}
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
settings.readerDoublePagesSensitivity = value / 100f
}
override fun onButtonChecked(
group: MaterialButtonToggleGroup?,
checkedId: Int,
isChecked: Boolean,
) {
if (!isChecked) {
return
}
val newMode = when (checkedId) {
R.id.button_standard -> ReaderMode.STANDARD
R.id.button_webtoon -> ReaderMode.WEBTOON
R.id.button_reversed -> ReaderMode.REVERSED
R.id.button_vertical -> ReaderMode.VERTICAL
else -> return
}
viewBinding?.run {
switchDoubleReader.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED
switchDoubleFoldable.isEnabled = switchDoubleReader.isEnabled
adjustSensitivitySlider(withAnimation = true)
}
if (newMode == mode) {
return
}
findParentCallback(Callback::class.java)?.onReaderModeChanged(newMode) ?: return
mode = newMode
}
private fun observeScreenOrientation() {
orientationHelper.observeAutoOrientation()
.onEach {
with(requireViewBinding()) {
buttonScreenRotate.isGone = it
switchScreenLockRotation.isVisible = it
updateOrientationLockSwitch()
}
}.launchIn(viewLifecycleScope)
}
private fun updateOrientationLockSwitch() {
val switch = viewBinding?.switchScreenLockRotation ?: return
switch.setOnCheckedChangeListener(null)
switch.isChecked = orientationHelper.isLocked
switch.setOnCheckedChangeListener(this)
}
private suspend fun bindImageServerTitle() {
viewBinding?.buttonImageServer?.text = getString(
R.string.inline_preference_pattern,
getString(R.string.image_server),
imageServerDelegate.getValue() ?: getString(R.string.automatic),
)
}
private fun SheetReaderConfigBinding.adjustSensitivitySlider(withAnimation: Boolean) {
val isSubOptionsVisible = switchDoubleReader.isEnabled && switchDoubleReader.isChecked
val needTransition = withAnimation && (
(isSubOptionsVisible != sliderDoubleSensitivity.isVisible) ||
(isSubOptionsVisible != textDoubleSensitivity.isVisible) ||
(isSubOptionsVisible != switchDoubleFoldable.isVisible)
)
if (needTransition) {
TransitionManager.beginDelayedTransition(layoutMain)
}
sliderDoubleSensitivity.isVisible = isSubOptionsVisible
textDoubleSensitivity.isVisible = isSubOptionsVisible
switchDoubleFoldable.isVisible = isSubOptionsVisible
}
interface Callback {
fun onReaderModeChanged(mode: ReaderMode)
fun onDoubleModeChanged(isEnabled: Boolean)
fun onSavePageClick()
fun onScrollTimerClick(isLongClick: Boolean)
fun onBookmarkClick()
}
}

@ -25,11 +25,26 @@ abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>(), ZoomCont
readerAdapter = onCreateAdapter()
viewModel.content.observe(viewLifecycleOwner) {
if (it.state == null && it.pages.isNotEmpty() && readerAdapter?.hasItems != true) {
onPagesChanged(it.pages, viewModel.getCurrentState())
} else {
onPagesChanged(it.pages, it.state)
// Determine which state to use for restoring position:
// - content.state: explicitly set state (e.g., after mode switch or chapter change)
// - getCurrentState(): current reading position saved in SavedStateHandle
val currentState = viewModel.getCurrentState()
val pendingState = when {
// If content.state is null and we have pages, use getCurrentState
it.state == null
&& it.pages.isNotEmpty()
&& readerAdapter?.hasItems != true -> currentState
// use currentState only if it matches the current pages (to avoid the error message)
readerAdapter?.hasItems != true
&& it.state != currentState
&& currentState != null
&& it.pages.any { page -> page.chapterId == currentState.chapterId } -> currentState
// Otherwise, use content.state (normal flow, mode switch, chapter change)
else -> it.state
}
onPagesChanged(it.pages, pendingState)
}
}

@ -94,7 +94,7 @@ class SearchActivity :
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
supportActionBar?.setSubtitle(R.string.search_results)
addMenuProvider(SearchKindMenuProvider(this, viewModel.query, viewModel.kind))
addMenuProvider(SearchMenuProvider(this, viewModel))
viewModel.list.observe(this, adapter)
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))

@ -9,10 +9,9 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.search.domain.SearchKind
class SearchKindMenuProvider(
class SearchMenuProvider(
private val activity: SearchActivity,
private val query: String,
private val kind: SearchKind
private val viewModel: SearchViewModel,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
@ -22,7 +21,7 @@ class SearchKindMenuProvider(
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(
when (kind) {
when (viewModel.kind) {
SearchKind.SIMPLE -> R.id.action_kind_simple
SearchKind.TITLE -> R.id.action_kind_title
SearchKind.AUTHOR -> R.id.action_kind_author
@ -32,6 +31,20 @@ class SearchKindMenuProvider(
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.action_filter_pinned_only -> {
menuItem.isChecked = !menuItem.isChecked
viewModel.setPinnedOnly(menuItem.isChecked)
return true
}
R.id.action_filter_hide_empty -> {
menuItem.isChecked = !menuItem.isChecked
viewModel.setHideEmpty(menuItem.isChecked)
return true
}
}
val newKind = when (menuItem.itemId) {
R.id.action_kind_simple -> SearchKind.SIMPLE
R.id.action_kind_title -> SearchKind.TITLE
@ -39,9 +52,9 @@ class SearchKindMenuProvider(
R.id.action_kind_tag -> SearchKind.TAG
else -> return false
}
if (newKind != kind) {
if (newKind != viewModel.kind) {
activity.router.openSearch(
query = query,
query = viewModel.query,
kind = newKind,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {

@ -62,6 +62,8 @@ class SearchViewModel @Inject constructor(
val kind = savedStateHandle.get<SearchKind>(AppRouter.KEY_KIND) ?: SearchKind.SIMPLE
private var includeDisabledSources = MutableStateFlow(false)
private var pinnedOnly = MutableStateFlow(false)
private var hideEmpty = MutableStateFlow(false)
private val results = MutableStateFlow<List<SearchResultsListModel>>(emptyList())
private var searchJob: Job? = null
@ -70,9 +72,15 @@ class SearchViewModel @Inject constructor(
results,
isLoading.dropWhile { !it },
includeDisabledSources,
) { list, loading, includeDisabled ->
hideEmpty,
) { list, loading, includeDisabled, hideEmptyVal ->
val filteredList = if (hideEmptyVal) {
list.filter { it.list.isNotEmpty() }
} else {
list
}
when {
list.isEmpty() -> listOf(
filteredList.isEmpty() -> listOf(
when {
loading -> LoadingState
else -> EmptyState(
@ -84,9 +92,9 @@ class SearchViewModel @Inject constructor(
},
)
loading -> list + LoadingFooter()
includeDisabled -> list
else -> list + ButtonFooter(R.string.search_disabled_sources)
loading -> filteredList + LoadingFooter()
includeDisabled -> filteredList
else -> filteredList + ButtonFooter(R.string.search_disabled_sources)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
@ -114,6 +122,17 @@ class SearchViewModel @Inject constructor(
doSearch()
}
fun setPinnedOnly(value: Boolean) {
if (pinnedOnly.value != value) {
pinnedOnly.value = value
retry()
}
}
fun setHideEmpty(value: Boolean) {
hideEmpty.value = value
}
fun continueSearch() {
if (includeDisabledSources.value) {
return
@ -122,8 +141,12 @@ class SearchViewModel @Inject constructor(
searchJob = launchLoadingJob(Dispatchers.Default) {
includeDisabledSources.value = true
prevJob?.join()
val sources = sourcesRepository.getDisabledSources()
.sortedByDescending { it.priority() }
val sources = if (pinnedOnly.value) {
emptyList()
} else {
sourcesRepository.getDisabledSources()
.sortedByDescending { it.priority() }
}
val semaphore = Semaphore(MAX_PARALLELISM)
sources.map { source ->
launch {
@ -142,7 +165,11 @@ class SearchViewModel @Inject constructor(
appendResult(searchHistory())
appendResult(searchFavorites())
appendResult(searchLocal())
val sources = sourcesRepository.getEnabledSources()
val sources = if (pinnedOnly.value) {
sourcesRepository.getPinnedSources().toList()
} else {
sourcesRepository.getEnabledSources()
}
val semaphore = Semaphore(MAX_PARALLELISM)
sources.map { source ->
launch {

@ -37,7 +37,7 @@ fun searchResultsAD(
binding.recyclerView.addItemDecoration(selectionDecoration)
binding.recyclerView.adapter = adapter
val spacing = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing, withBottomPadding = true))
val eventListener = AdapterDelegateClickListenerAdapter(this, itemClickListener)
binding.buttonMore.setOnClickListener(eventListener)

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

@ -1,38 +1,66 @@
package org.koitharu.kotatsu.settings.storage.directories
import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.core.text.bold
import androidx.core.text.buildSpannedString
import androidx.core.text.color
import androidx.core.view.isGone
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.drawableStart
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.setTooltipCompat
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemStorageConfigBinding
import org.koitharu.kotatsu.settings.storage.DirectoryModel
import org.koitharu.kotatsu.databinding.ItemStorageConfig2Binding
fun directoryConfigAD(
clickListener: OnListItemClickListener<DirectoryModel>,
) = adapterDelegateViewBinding<DirectoryModel, DirectoryModel, ItemStorageConfigBinding>(
{ layoutInflater, parent -> ItemStorageConfigBinding.inflate(layoutInflater, parent, false) },
clickListener: OnListItemClickListener<DirectoryConfigModel>,
) = adapterDelegateViewBinding<DirectoryConfigModel, DirectoryConfigModel, ItemStorageConfig2Binding>(
{ layoutInflater, parent -> ItemStorageConfig2Binding.inflate(layoutInflater, parent, false) },
) {
binding.buttonRemove.setOnClickListener { v -> clickListener.onItemClick(item, v) }
binding.buttonRemove.setTooltipCompat(binding.buttonRemove.contentDescription)
binding.buttonRemove.setOnClickListener { v -> clickListener.onItemClick(item, v) }
binding.buttonRemove.setTooltipCompat(binding.buttonRemove.contentDescription)
bind {
binding.textViewTitle.text = item.title ?: getString(item.titleRes)
binding.textViewSubtitle.textAndVisible = item.file?.absolutePath
binding.buttonRemove.isVisible = item.isRemovable
binding.buttonRemove.isEnabled = !item.isChecked
binding.textViewTitle.drawableStart = if (!item.isAvailable) {
ContextCompat.getDrawable(context, R.drawable.ic_alert_outline)?.apply {
setTint(ContextCompat.getColor(context, R.color.warning))
}
} else if (item.isChecked) {
ContextCompat.getDrawable(context, R.drawable.ic_download)
} else {
null
}
}
bind {
binding.textViewTitle.text = item.title
binding.textViewSubtitle.text = item.path.absolutePath
binding.buttonRemove.isGone = item.isAppPrivate
binding.buttonRemove.isEnabled = !item.isDefault
binding.spacer.visibility = if (item.isAppPrivate) {
View.INVISIBLE
} else {
View.GONE
}
binding.textViewInfo.textAndVisible = buildSpannedString {
if (item.isDefault) {
bold {
append(getString(R.string.download_default_directory))
}
}
if (!item.isAccessible) {
if (isNotEmpty()) appendLine()
color(
context.getThemeColor(
androidx.appcompat.R.attr.colorError,
ContextCompat.getColor(context, R.color.common_red),
),
) {
append(getString(R.string.no_write_permission_to_file))
}
}
if (item.isAppPrivate) {
if (isNotEmpty()) appendLine()
append(getString(R.string.private_app_directory_warning))
}
}
binding.indicatorSize.max = FileSize.BYTES.convert(item.available, FileSize.KILOBYTES).toInt()
binding.indicatorSize.progress = FileSize.BYTES.convert(item.size, FileSize.KILOBYTES).toInt()
binding.textViewSize.text = context.getString(
R.string.available_pattern,
FileSize.BYTES.format(context, item.available),
)
}
}

@ -0,0 +1,22 @@
package org.koitharu.kotatsu.settings.storage.directories
import androidx.recyclerview.widget.DiffUtil.ItemCallback
class DirectoryConfigDiffCallback : ItemCallback<DirectoryConfigModel>() {
override fun areItemsTheSame(oldItem: DirectoryConfigModel, newItem: DirectoryConfigModel): Boolean {
return oldItem.path == newItem.path
}
override fun areContentsTheSame(oldItem: DirectoryConfigModel, newItem: DirectoryConfigModel): Boolean {
return oldItem == newItem
}
override fun getChangePayload(oldItem: DirectoryConfigModel, newItem: DirectoryConfigModel): Any? {
return if (oldItem.isDefault != newItem.isDefault) {
Unit
} else {
super.getChangePayload(oldItem, newItem)
}
}
}

@ -0,0 +1,19 @@
package org.koitharu.kotatsu.settings.storage.directories
import org.koitharu.kotatsu.list.ui.model.ListModel
import java.io.File
data class DirectoryConfigModel(
val title: String,
val path: File,
val isDefault: Boolean,
val isAppPrivate: Boolean,
val isAccessible: Boolean,
val size: Long,
val available: Long,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is DirectoryConfigModel && path == other.path
}
}

@ -20,18 +20,17 @@ import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.databinding.ActivityMangaDirectoriesBinding
import org.koitharu.kotatsu.settings.storage.DirectoryDiffCallback
import org.koitharu.kotatsu.settings.storage.DirectoryModel
import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract
@AndroidEntryPoint
class MangaDirectoriesActivity : BaseActivity<ActivityMangaDirectoriesBinding>(),
OnListItemClickListener<DirectoryModel>, View.OnClickListener {
OnListItemClickListener<DirectoryConfigModel>, View.OnClickListener {
private val viewModel: MangaDirectoriesViewModel by viewModels()
private val pickFileTreeLauncher = OpenDocumentTreeHelper(
@ -63,8 +62,10 @@ class MangaDirectoriesActivity : BaseActivity<ActivityMangaDirectoriesBinding>()
super.onCreate(savedInstanceState)
setContentView(ActivityMangaDirectoriesBinding.inflate(layoutInflater))
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
val adapter = AsyncListDifferDelegationAdapter(DirectoryDiffCallback(), directoryConfigAD(this))
viewBinding.recyclerView.adapter = adapter
val adapter = AsyncListDifferDelegationAdapter(DirectoryConfigDiffCallback(), directoryConfigAD(this))
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing_large)
viewBinding.recyclerView.adapter = adapter
viewBinding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing, withBottomPadding = false))
viewBinding.fabAdd.setOnClickListener(this)
viewModel.items.observe(this) { adapter.items = it }
viewModel.isLoading.observe(this) { viewBinding.progressBar.isVisible = it }
@ -76,8 +77,8 @@ class MangaDirectoriesActivity : BaseActivity<ActivityMangaDirectoriesBinding>()
)
}
override fun onItemClick(item: DirectoryModel, view: View) {
viewModel.onRemoveClick(item.file ?: return)
override fun onItemClick(item: DirectoryConfigModel, view: View) {
viewModel.onRemoveClick(item.path)
}
override fun onClick(v: View?) {

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.settings.storage.directories
import android.net.Uri
import android.os.StatFs
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -8,82 +9,87 @@ import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.isReadable
import org.koitharu.kotatsu.core.util.ext.isWriteable
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.settings.storage.DirectoryModel
import java.io.File
import javax.inject.Inject
@HiltViewModel
class MangaDirectoriesViewModel @Inject constructor(
private val storageManager: LocalStorageManager,
private val settings: AppSettings,
private val storageManager: LocalStorageManager,
private val settings: AppSettings,
) : BaseViewModel() {
val items = MutableStateFlow(emptyList<DirectoryModel>())
private var loadingJob: Job? = null
val items = MutableStateFlow(emptyList<DirectoryConfigModel>())
private var loadingJob: Job? = null
init {
loadList()
}
init {
loadList()
}
fun updateList() {
loadList()
}
fun updateList() {
loadList()
}
fun onCustomDirectoryPicked(uri: Uri) {
launchLoadingJob(Dispatchers.Default) {
loadingJob?.cancelAndJoin()
storageManager.takePermissions(uri)
val dir = storageManager.resolveUri(uri)
if (!dir.canRead()) {
throw AccessDeniedException(dir)
}
if (dir !in storageManager.getApplicationStorageDirs()) {
settings.userSpecifiedMangaDirectories += dir
loadList()
}
}
}
fun onCustomDirectoryPicked(uri: Uri) {
launchLoadingJob(Dispatchers.Default) {
loadingJob?.cancelAndJoin()
storageManager.takePermissions(uri)
val dir = storageManager.resolveUri(uri)
if (!dir.canRead()) {
throw AccessDeniedException(dir)
}
if (dir !in storageManager.getApplicationStorageDirs()) {
settings.userSpecifiedMangaDirectories += dir
loadList()
}
}
}
fun onRemoveClick(directory: File) {
settings.userSpecifiedMangaDirectories -= directory
if (settings.mangaStorageDir == directory) {
settings.mangaStorageDir = null
}
loadList()
}
fun onRemoveClick(directory: File) {
settings.userSpecifiedMangaDirectories -= directory
if (settings.mangaStorageDir == directory) {
settings.mangaStorageDir = null
}
loadList()
}
private fun loadList() {
val prevJob = loadingJob
loadingJob = launchJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
val downloadDir = storageManager.getDefaultWriteableDir()
val applicationDirs = storageManager.getApplicationStorageDirs()
val customDirs = settings.userSpecifiedMangaDirectories - applicationDirs
items.value = buildList(applicationDirs.size + customDirs.size) {
applicationDirs.mapTo(this) { dir ->
DirectoryModel(
title = storageManager.getDirectoryDisplayName(dir, isFullPath = false),
titleRes = 0,
file = dir,
isChecked = dir == downloadDir,
isAvailable = dir.isReadable() && dir.isWriteable(),
isRemovable = false,
)
}
customDirs.mapTo(this) { dir ->
DirectoryModel(
title = storageManager.getDirectoryDisplayName(dir, isFullPath = false),
titleRes = 0,
file = dir,
isChecked = dir == downloadDir,
isAvailable = dir.isReadable() && dir.isWriteable(),
isRemovable = true,
)
}
}
}
}
private fun loadList() {
val prevJob = loadingJob
loadingJob = launchJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
val downloadDir = storageManager.getDefaultWriteableDir()
val applicationDirs = storageManager.getApplicationStorageDirs()
val customDirs = settings.userSpecifiedMangaDirectories - applicationDirs
items.value = buildList(applicationDirs.size + customDirs.size) {
applicationDirs.mapTo(this) { dir ->
dir.toDirectoryModel(
isDefault = dir == downloadDir,
isAppPrivate = true,
)
}
customDirs.mapTo(this) { dir ->
dir.toDirectoryModel(
isDefault = dir == downloadDir,
isAppPrivate = false,
)
}
}
}
}
private suspend fun File.toDirectoryModel(
isDefault: Boolean,
isAppPrivate: Boolean,
) = DirectoryConfigModel(
title = storageManager.getDirectoryDisplayName(this, isFullPath = false),
path = this,
isDefault = isDefault,
isAccessible = isReadable() && isWriteable(),
isAppPrivate = isAppPrivate,
size = computeSize(),
available = StatFs(this.absolutePath).availableBytes,
)
}

@ -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">
<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>
</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" />
<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" />
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.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.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: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.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>

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

@ -19,6 +19,7 @@
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:fillViewport="true"
android:scrollIndicators="top|bottom"
tools:ignore="UnusedAttribute">

@ -1,216 +1,218 @@
<?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">
<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">
<LinearLayout
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_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" />
<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.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_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.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" />
<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" />
<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.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" />
<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_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" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
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" />
<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: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_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" />
<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.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_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.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" />
<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_foldable"
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/auto_double_foldable"
android:textAppearance="@style/TextAppearance.Kotatsu.GridTitle"
android:textColor="?colorOnSurfaceVariant"
android:visibility="gone"
tools:visibility="visible" />
<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/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" />
<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" />
<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_settings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/settings"
app:drawableStartCompat="@drawable/ic_settings" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</LinearLayout>

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

@ -33,6 +33,20 @@
android:title="@string/genre" />
</group>
<group android:id="@+id/group_search_filters">
<item
android:id="@+id/action_filter_pinned_only"
android:checkable="true"
android:title="@string/pinned_sources_only" />
<item
android:id="@+id/action_filter_hide_empty"
android:checkable="true"
android:title="@string/hide_empty_sources" />
</group>
</menu>
</item>

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

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

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

@ -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,24 @@
<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>
<string name="pinned_sources_only">Samo prikvačeni izvori</string>
<string name="hide_empty_sources">Sakrij prazne izvore</string>
</resources>

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

@ -865,4 +865,24 @@
<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>
<string name="pinned_sources_only">Hanya sumber yang disematkan</string>
<string name="hide_empty_sources">Sembunyikan sumber kosong</string>
</resources>

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

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

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

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

@ -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,32 @@
<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>
<string name="hide_empty_sources">Esconder fontes vazias</string>
<string name="pinned_sources_only">Apenas fontes fixadas</string>
<string name="auto_double_foldable">Duas Páginas Automático em Tela Dobrável</string>
</resources>

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

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

@ -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,24 @@
<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>
<string name="pinned_sources_only">Yalnızca sabitlenen kaynaklar</string>
<string name="hide_empty_sources">Boş kaynakları gizle</string>
<string name="auto_double_foldable">Otomatik İki Sayfa Katlanabilir</string>
</resources>

@ -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,24 @@
<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>
<string name="frequency_every_6_hours">Кожні 6 годин</string>
<string name="download_default_directory">Каталог за замовчуванням для завантаження манги</string>
<string name="private_app_directory_warning">Цей каталог з усіма даними буде видалено, якщо ви видалите програму</string>
<string name="available_pattern">Доступно %1$s</string>
<string name="auto_double_foldable">Автоматичне двосторінкове розміщення на складному</string>
<string name="pinned_sources_only">Тільки закріплені джерела</string>
<string name="hide_empty_sources">Приховати порожні джерела</string>
</resources>

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

@ -866,4 +866,5 @@
<string name="pull_bottom_no_next">到底了</string>
<string name="enable_pull_gesture_title">启用推拉手势</string>
<string name="enable_pull_gesture_summary">条漫模式下使用推拉手势切换章节</string>
<string name="auto_double_foldable">折叠设备自动双页</string>
</resources>

@ -661,4 +661,5 @@
<string name="download_new_chapters">下載新的漫畫章節</string>
<string name="enable_all_sources">啟用所有漫畫來源</string>
<string name="all_sources_enabled">所有漫畫來源已啟用</string>
<string name="auto_double_foldable">摺疊設備自動雙頁</string>
</resources>

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

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

@ -210,6 +210,8 @@
<string name="disabled">Disabled</string>
<string name="reset_filter">Reset filter</string>
<string name="enter_name">Enter name</string>
<string name="pinned_sources_only">Pinned sources only</string>
<string name="hide_empty_sources">Hide empty sources</string>
<string name="onboard_text">Select languages which you want to read manga. You can change it later in settings.</string>
<string name="never">Never</string>
<string name="only_using_wifi">Only on Wi-Fi</string>
@ -498,6 +500,7 @@
<string name="online_variant">Online variant</string>
<string name="periodic_backups">Periodic backups</string>
<string name="backup_frequency">Backup creation frequency</string>
<string name="frequency_every_6_hours">Every 6 hours</string>
<string name="frequency_every_day">Every day</string>
<string name="frequency_every_2_days">Every 2 days</string>
<string name="frequency_once_per_week">Once per week</string>
@ -578,6 +581,7 @@
<string name="none">None</string>
<string name="config_reset_confirm">Reset settings to default values? This action cannot be undone.</string>
<string name="use_two_pages_landscape">Use two pages layout on landscape orientation (beta)</string>
<string name="auto_double_foldable">Auto Two-Page On Foldable</string>
<string name="two_page_scroll_sensitivity">Two-Page Scroll Sensitivity</string>
<string name="default_webtoon_zoom_out">Default webtoon zoom out</string>
<string name="fullscreen_mode">Fullscreen mode</string>
@ -900,4 +904,7 @@
<string name="data_removal">Data removal</string>
<string name="privacy">Privacy</string>
<string name="source_broken_warning">This manga source has been marked as broken. Some features may not work</string>
<string name="download_default_directory">Default directory for downloading manga</string>
<string name="private_app_directory_warning">This directory with all data will be deleted if you uninstall the application</string>
<string name="available_pattern">%1$s available</string>
</resources>

@ -87,6 +87,12 @@
android:title="@string/pages_animation"
app:useSimpleSummaryProvider="true" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="webtoon_pull_gesture"
android:summary="@string/enable_pull_gesture_summary"
android:title="@string/enable_pull_gesture_title" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="enhanced_colors"

@ -35,7 +35,7 @@ material = "1.14.0-alpha05"
moshi = "1.15.2"
okhttp = "5.2.1"
okio = "3.16.1"
parsers = "df1cab3f9d"
parsers = "4d1e521aef"
preference = "1.2.1"
recyclerview = "1.4.0"
room = "2.7.2"
@ -49,6 +49,7 @@ viewpager2 = "1.1.0"
webkit = "1.14.0"
workRuntime = "2.10.5"
workinspector = "1.2"
window = "1.3.0"
[libraries]
acra-dialog = { module = "ch.acra:acra-dialog", version.ref = "acra" }
@ -115,6 +116,7 @@ okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
ssiv = { module = "com.github.KotatsuApp:subsampling-scale-image-view", version.ref = "ssiv" }
workinspector = { module = "com.github.Koitharu:WorkInspector", version.ref = "workinspector" }
androidx-window = { module = "androidx.window:window", version.ref = "window" }
[plugins]
android-application = { id = "com.android.application", version.ref = "gradle" }

Loading…
Cancel
Save