Compare commits

..

No commits in common. 'devel' and 'master' have entirely different histories.

@ -4,7 +4,7 @@ root = true
charset = utf-8 charset = utf-8
end_of_line = lf end_of_line = lf
indent_size = 4 indent_size = 4
indent_style = space indent_style = tab
insert_final_newline = true insert_final_newline = true
max_line_length = 120 max_line_length = 120
tab_width = 4 tab_width = 4

1
.gitignore vendored

@ -6,7 +6,6 @@
/.idea/dictionaries /.idea/dictionaries
/.idea/modules.xml /.idea/modules.xml
/.idea/misc.xml /.idea/misc.xml
/.idea/markdown.xml
/.idea/discord.xml /.idea/discord.xml
/.idea/compiler.xml /.idea/compiler.xml
/.idea/workspace.xml /.idea/workspace.xml

2
.idea/.gitignore vendored

@ -3,5 +3,3 @@
/workspace.xml /workspace.xml
/migrations.xml /migrations.xml
/runConfigurations.xml /runConfigurations.xml
/appInsightsSettings.xml
/kotlinCodeInsightSettings.xml

@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="tabSettings">
<map>
<entry key="Firebase Crashlytics">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="PLACEHOLDER" />
<option name="mobileSdkAppId" value="" />
<option name="projectId" value="" />
<option name="projectNumber" value="" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
</map>
</option>
</component>
</project>

@ -1,7 +1,9 @@
<component name="ProjectCodeStyleConfiguration"> <component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173"> <code_scheme name="Project" version="173">
<option name="OTHER_INDENT_OPTIONS"> <option name="OTHER_INDENT_OPTIONS">
<value /> <value>
<option name="USE_TAB_CHARACTER" value="true" />
</value>
</option> </option>
<AndroidXmlCodeStyleSettings> <AndroidXmlCodeStyleSettings>
<option name="LAYOUT_SETTINGS"> <option name="LAYOUT_SETTINGS">
@ -20,46 +22,40 @@
</value> </value>
</option> </option>
</AndroidXmlCodeStyleSettings> </AndroidXmlCodeStyleSettings>
<JavaCodeStyleSettings>
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="android" withSubpackages="true" static="true" />
<package name="androidx" withSubpackages="true" static="true" />
<package name="com" withSubpackages="true" static="true" />
<package name="junit" withSubpackages="true" static="true" />
<package name="net" withSubpackages="true" static="true" />
<package name="org" withSubpackages="true" static="true" />
<package name="java" withSubpackages="true" static="true" />
<package name="javax" withSubpackages="true" static="true" />
<package name="" withSubpackages="true" static="true" />
<emptyLine />
<package name="android" withSubpackages="true" static="false" />
<emptyLine />
<package name="androidx" withSubpackages="true" static="false" />
<emptyLine />
<package name="com" withSubpackages="true" static="false" />
<emptyLine />
<package name="junit" withSubpackages="true" static="false" />
<emptyLine />
<package name="net" withSubpackages="true" static="false" />
<emptyLine />
<package name="org" withSubpackages="true" static="false" />
<emptyLine />
<package name="java" withSubpackages="true" static="false" />
<emptyLine />
<package name="javax" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
<emptyLine />
</value>
</option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings> <JetCodeStyleSettings>
<option name="ALLOW_TRAILING_COMMA" value="true" /> <option name="ALLOW_TRAILING_COMMA" value="true" />
<option name="ALLOW_TRAILING_COMMA_COLLECTION_LITERAL_EXPRESSION" value="true" />
<option name="ALLOW_TRAILING_COMMA_VALUE_ARGUMENT_LIST" value="true" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" /> <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings> </JetCodeStyleSettings>
<codeStyleSettings language="CMake">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Groovy">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="HTML">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JAVA">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JSON">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="ObjectiveC">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Shell Script"> <codeStyleSettings language="Shell Script">
<indentOptions> <indentOptions>
<option name="USE_TAB_CHARACTER" value="true" /> <option name="USE_TAB_CHARACTER" value="true" />
@ -68,6 +64,7 @@
<codeStyleSettings language="XML"> <codeStyleSettings language="XML">
<indentOptions> <indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" /> <option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions> </indentOptions>
<arrangement> <arrangement>
<rules> <rules>
@ -182,6 +179,9 @@
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" /> <option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" /> <option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="LINE_COMMENT_ADD_SPACE" value="true" /> <option name="LINE_COMMENT_ADD_SPACE" value="true" />
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings> </codeStyleSettings>
</code_scheme> </code_scheme>
</component> </component>

@ -6,7 +6,7 @@
<GradleProjectSettings> <GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" /> <option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" /> <option name="gradleJvm" value="jbr-21" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />

@ -1,16 +1,24 @@
> [!IMPORTANT] <div align="center">
> 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.
--- <a href="https://kotatsu.app">
<img src="./.github/assets/vtuber.png" alt="Kotatsu Logo" title="Kotatsu" width="600"/>
</a>
<div align="center"> # [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.**
![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 5.0](https://img.shields.io/badge/android-5.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)
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in ### Download
online content sources.**
![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) <div align="left">
* **Recommended:** Download and install APK from [GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest). Application has a built-in self-updating feature.
* Get it on [F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu). The F-Droid build may be a bit outdated and some fixes might be missing.
* Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (Unstable, use at your own risk). Application has a built-in self-updating feature.
</div>
### Main Features ### Main Features
@ -27,7 +35,7 @@ online content sources.**
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu * Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
* Password / fingerprint-protected access to the app * Password / fingerprint-protected access to the app
* Automatically sync app data with other devices on the same account * Automatically sync app data with other devices on the same account
* Support for older devices running Android 6.0+ * Support for older devices running Android 5.0+
</div> </div>
@ -78,8 +86,7 @@ please head over to the [Weblate project page](https://hosted.weblate.org/engage
</br> </br>
**📌 Pull requests are welcome, if you want: **📌 Pull requests are welcome, if you want: See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
### Certificate fingerprints ### Certificate fingerprints
@ -97,9 +104,7 @@ See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUT
<div align="left"> <div align="left">
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications 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.
to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build &
install instructions.
</div> </div>
@ -107,9 +112,6 @@ install instructions.
<div align="left"> <div align="left">
The developers of this application do not have any affiliation with the content available in the app and does not store The developers of this application do not have any affiliation with the content available in the app. It collects content from sources that are freely available through any web browser.
or distribute any content. This application should be considered a web browser, all content that can be found using this
application is freely available on the Internet. All DMCA takedown requests should be sent to the owners of the website
where the content is hosted.
</div> </div>

@ -19,10 +19,10 @@ android {
defaultConfig { defaultConfig {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 23 minSdk = 21
targetSdk = 36 targetSdk = 36
versionCode = 1033 versionCode = 1028
versionName = '9.4.1' versionName = '9.1.4'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {
@ -87,7 +87,6 @@ android {
'-opt-in=coil3.annotation.InternalCoilApi', '-opt-in=coil3.annotation.InternalCoilApi',
'-opt-in=kotlinx.serialization.ExperimentalSerializationApi', '-opt-in=kotlinx.serialization.ExperimentalSerializationApi',
'-Xjspecify-annotations=strict', '-Xjspecify-annotations=strict',
'-Xannotation-default-target=first-only',
'-Xtype-enhancement-improvements-strict-mode' '-Xtype-enhancement-improvements-strict-mode'
] ]
} }
@ -155,9 +154,6 @@ dependencies {
implementation libs.androidx.work.runtime implementation libs.androidx.work.runtime
implementation libs.guava implementation libs.guava
// Foldable/Window layout
implementation libs.androidx.window
implementation libs.androidx.room.runtime implementation libs.androidx.room.runtime
implementation libs.androidx.room.ktx implementation libs.androidx.room.ktx
ksp libs.androidx.room.compiler ksp libs.androidx.room.compiler

@ -8,7 +8,8 @@
public static void checkParameterIsNotNull(...); public static void checkParameterIsNotNull(...);
public static void checkNotNullParameter(...); public static void checkNotNullParameter(...);
} }
-keep public class ** extends org.koitharu.kotatsu.core.ui.BaseFragment
-keep class org.koitharu.kotatsu.core.db.entity.* { *; }
-dontwarn okhttp3.internal.platform.** -dontwarn okhttp3.internal.platform.**
-dontwarn org.conscrypt.** -dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.** -dontwarn org.bouncycastle.**
@ -16,10 +17,8 @@
-dontwarn com.google.j2objc.annotations.** -dontwarn com.google.j2objc.annotations.**
-dontwarn coil3.PlatformContext -dontwarn coil3.PlatformContext
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
-keep class org.koitharu.kotatsu.settings.about.changelog.ChangelogFragment
-keep class org.koitharu.kotatsu.core.exceptions.* { *; } -keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; } -keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
-keep class org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment { *; } -keep class org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment { *; }
-keep class org.jsoup.parser.Tag -keep class org.jsoup.parser.Tag

@ -41,8 +41,8 @@ class KotatsuApp : BaseApp() {
detectNetwork() detectNetwork()
detectDiskWrites() detectDiskWrites()
detectCustomSlowCalls() detectCustomSlowCalls()
detectResourceMismatches()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) detectResourceMismatches()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) detectExplicitGc() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) detectExplicitGc()
penaltyLog() penaltyLog()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {

@ -1,57 +0,0 @@
package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.model.TestMangaSource
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet
/*
This class is for parser development and testing purposes
You can open it in the app via Settings -> Debug
*/
class TestMangaRepository(
@Suppress("unused") private val loaderContext: MangaLoaderContext,
cache: MemoryContentCache
) : CachingMangaRepository(cache) {
override val source = TestMangaSource
override val sortOrders: Set<SortOrder> = EnumSet.allOf(SortOrder::class.java)
override var defaultSortOrder: SortOrder
get() = sortOrders.first()
set(value) = Unit
override val filterCapabilities = MangaListFilterCapabilities()
override suspend fun getFilterOptions() = MangaListFilterOptions()
override suspend fun getList(
offset: Int,
order: SortOrder?,
filter: MangaListFilter?
): List<Manga> = TODO("Get manga list by filter")
override suspend fun getDetailsImpl(
manga: Manga
): Manga = TODO("Fetch manga details")
override suspend fun getPagesImpl(
chapter: MangaChapter
): List<MangaPage> = TODO("Get pages for specific chapter")
override suspend fun getPageUrl(
page: MangaPage
): String = TODO("Return direct url of page image or page.url if it is already a direct url")
override suspend fun getRelatedMangaImpl(
seed: Manga
): List<Manga> = TODO("Get list of related manga. This method is optional and parser library has a default implementation")
}

@ -5,8 +5,6 @@ import androidx.preference.Preference
import leakcanary.LeakCanary import leakcanary.LeakCanary
import org.koitharu.kotatsu.KotatsuApp import org.koitharu.kotatsu.KotatsuApp
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.TestMangaSource
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.settings.utils.SplitSwitchPreference import org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
import org.koitharu.workinspector.WorkInspector import org.koitharu.workinspector.WorkInspector
@ -37,11 +35,6 @@ class DebugSettingsFragment : BasePreferenceFragment(R.string.debug), Preference
true true
} }
KEY_TEST_PARSER -> {
router.openList(TestMangaSource, null, null)
true
}
else -> super.onPreferenceTreeClick(preference) else -> super.onPreferenceTreeClick(preference)
} }
@ -67,6 +60,5 @@ class DebugSettingsFragment : BasePreferenceFragment(R.string.debug), Preference
const val KEY_LEAK_CANARY = "leak_canary" const val KEY_LEAK_CANARY = "leak_canary"
const val KEY_WORK_INSPECTOR = "work_inspector" const val KEY_WORK_INSPECTOR = "work_inspector"
const val KEY_TEST_PARSER = "test_parser"
} }
} }

@ -1,23 +1,17 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen <androidx.preference.PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:app="http://schemas.android.com/apk/res-auto">
<org.koitharu.kotatsu.settings.utils.SplitSwitchPreference <org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
android:id="@+id/action_leakcanary"
android:key="leak_canary" android:key="leak_canary"
android:persistent="false" android:persistent="false"
android:title="LeakCanary" /> android:title="LeakCanary" />
<Preference <Preference
android:id="@+id/action_works"
android:key="work_inspector" android:key="work_inspector"
android:persistent="false" android:persistent="false"
android:title="@string/wi_lib_name" /> android:title="@string/wi_lib_name" />
<Preference
android:key="test_parser"
android:persistent="false"
android:title="@string/test_parser"
app:allowDividerAbove="true" />
</androidx.preference.PreferenceScreen> </androidx.preference.PreferenceScreen>

@ -51,11 +51,9 @@
android:backupAgent="org.koitharu.kotatsu.backups.domain.AppBackupAgent" android:backupAgent="org.koitharu.kotatsu.backups.domain.AppBackupAgent"
android:dataExtractionRules="@xml/backup_rules" android:dataExtractionRules="@xml/backup_rules"
android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled" android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled"
android:extractNativeLibs="true"
android:fullBackupContent="@xml/backup_content" android:fullBackupContent="@xml/backup_content"
android:fullBackupOnly="true" android:fullBackupOnly="true"
android:hasFragileUserData="true" android:hasFragileUserData="true"
android:restoreAnyVersion="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:largeHeap="true" android:largeHeap="true"

@ -26,17 +26,12 @@ import org.koitharu.kotatsu.backups.data.model.CategoryBackup
import org.koitharu.kotatsu.backups.data.model.FavouriteBackup import org.koitharu.kotatsu.backups.data.model.FavouriteBackup
import org.koitharu.kotatsu.backups.data.model.HistoryBackup import org.koitharu.kotatsu.backups.data.model.HistoryBackup
import org.koitharu.kotatsu.backups.data.model.MangaBackup import org.koitharu.kotatsu.backups.data.model.MangaBackup
import org.koitharu.kotatsu.backups.data.model.ScrobblingBackup
import org.koitharu.kotatsu.backups.data.model.SourceBackup import org.koitharu.kotatsu.backups.data.model.SourceBackup
import org.koitharu.kotatsu.backups.data.model.StatisticBackup
import org.koitharu.kotatsu.backups.domain.BackupSection import org.koitharu.kotatsu.backups.domain.BackupSection
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.CompositeResult import org.koitharu.kotatsu.core.util.CompositeResult
import org.koitharu.kotatsu.core.util.progress.Progress import org.koitharu.kotatsu.core.util.progress.Progress
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.filter.data.PersistableFilter
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.data.TapGridSettings import org.koitharu.kotatsu.reader.data.TapGridSettings
import java.io.InputStream import java.io.InputStream
@ -51,8 +46,6 @@ class BackupRepository @Inject constructor(
private val database: MangaDatabase, private val database: MangaDatabase,
private val settings: AppSettings, private val settings: AppSettings,
private val tapGridSettings: TapGridSettings, private val tapGridSettings: TapGridSettings,
private val mangaSourcesRepository: MangaSourcesRepository,
private val savedFiltersRepository: SavedFiltersRepository,
) { ) {
private val json = Json { private val json = Json {
@ -116,30 +109,6 @@ class BackupRepository @Inject constructor(
data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) }, data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) },
serializer = serializer(), 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) progress?.emit(commonProgress)
commonProgress++ commonProgress++
@ -159,7 +128,7 @@ class BackupRepository @Inject constructor(
while (entry != null) { while (entry != null) {
val section = BackupSection.of(entry) val section = BackupSection.of(entry)
if (section in sections) { if (section in sections) {
result += when (section) { result = result + when (section) {
BackupSection.INDEX -> CompositeResult.EMPTY // useless in our case BackupSection.INDEX -> CompositeResult.EMPTY // useless in our case
BackupSection.HISTORY -> input.readJsonArray<HistoryBackup>(serializer()).restoreToDb { BackupSection.HISTORY -> input.readJsonArray<HistoryBackup>(serializer()).restoreToDb {
upsertManga(it.manga) upsertManga(it.manga)
@ -194,19 +163,6 @@ class BackupRepository @Inject constructor(
getSourcesDao().upsert(it.toEntity()) 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 null -> CompositeResult.EMPTY // skip unknown entries
} }
progress?.emit(commonProgress) progress?.emit(commonProgress)
@ -303,12 +259,4 @@ class BackupRepository @Inject constructor(
} }
} }
} }
private suspend inline fun <T> Sequence<T>.restoreWithoutTransaction(crossinline block: suspend (T) -> Unit): CompositeResult {
return fold(CompositeResult.EMPTY) { result, item ->
result + runCatchingCancellable {
block(item)
}
}
}
} }

@ -1,40 +0,0 @@
package org.koitharu.kotatsu.backups.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
@Serializable
class ScrobblingBackup(
@SerialName("scrobbler") val scrobbler: Int,
@SerialName("id") val id: Int,
@SerialName("manga_id") val mangaId: Long,
@SerialName("target_id") val targetId: Long,
@SerialName("status") val status: String?,
@SerialName("chapter") val chapter: Int,
@SerialName("comment") val comment: String?,
@SerialName("rating") val rating: Float,
) {
constructor(entity: ScrobblingEntity) : this(
scrobbler = entity.scrobbler,
id = entity.id,
mangaId = entity.mangaId,
targetId = entity.targetId,
status = entity.status,
chapter = entity.chapter,
comment = entity.comment,
rating = entity.rating,
)
fun toEntity() = ScrobblingEntity(
scrobbler = scrobbler,
id = id,
mangaId = mangaId,
targetId = targetId,
status = status,
chapter = chapter,
comment = comment,
rating = rating,
)
}

@ -1,28 +0,0 @@
package org.koitharu.kotatsu.backups.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.koitharu.kotatsu.stats.data.StatsEntity
@Serializable
class StatisticBackup(
@SerialName("manga_id") val mangaId: Long,
@SerialName("started_at") val startedAt: Long,
@SerialName("duration") val duration: Long,
@SerialName("pages") val pages: Int,
) {
constructor(entity: StatsEntity) : this(
mangaId = entity.mangaId,
startedAt = entity.startedAt,
duration = entity.duration,
pages = entity.pages,
)
fun toEntity() = StatsEntity(
mangaId = mangaId,
startedAt = startedAt,
duration = duration,
pages = pages,
)
}

@ -12,8 +12,6 @@ import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.backups.data.BackupRepository import org.koitharu.kotatsu.backups.data.BackupRepository
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
import org.koitharu.kotatsu.reader.data.TapGridSettings import org.koitharu.kotatsu.reader.data.TapGridSettings
import java.io.File import java.io.File
import java.io.FileDescriptor import java.io.FileDescriptor
@ -38,20 +36,13 @@ class AppBackupAgent : BackupAgent() {
override fun onFullBackup(data: FullBackupDataOutput) { override fun onFullBackup(data: FullBackupDataOutput) {
super.onFullBackup(data) super.onFullBackup(data)
val file = createBackupFile( val file =
createBackupFile(
this, this,
BackupRepository( BackupRepository(
database = MangaDatabase(context = applicationContext), MangaDatabase(context = applicationContext),
settings = AppSettings(applicationContext), AppSettings(applicationContext),
tapGridSettings = TapGridSettings(applicationContext), TapGridSettings(applicationContext),
mangaSourcesRepository = MangaSourcesRepository(
context = applicationContext,
db = MangaDatabase(context = applicationContext),
settings = AppSettings(applicationContext),
),
savedFiltersRepository = SavedFiltersRepository(
context = applicationContext,
),
), ),
) )
try { try {
@ -77,14 +68,6 @@ class AppBackupAgent : BackupAgent() {
database = MangaDatabase(applicationContext), database = MangaDatabase(applicationContext),
settings = AppSettings(applicationContext), settings = AppSettings(applicationContext),
tapGridSettings = TapGridSettings(applicationContext), tapGridSettings = TapGridSettings(applicationContext),
mangaSourcesRepository = MangaSourcesRepository(
context = applicationContext,
db = MangaDatabase(context = applicationContext),
settings = AppSettings(applicationContext),
),
savedFiltersRepository = SavedFiltersRepository(
context = applicationContext,
),
), ),
) )
destination.delete() destination.delete()
@ -107,12 +90,8 @@ class AppBackupAgent : BackupAgent() {
@VisibleForTesting @VisibleForTesting
fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) { fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) {
ZipInputStream(ByteStreams.limit(FileInputStream(fd), size)).use { input -> ZipInputStream(ByteStreams.limit(FileInputStream(fd), size)).use { input ->
val sections = EnumSet.allOf(BackupSection::class.java)
// managed externally
sections.remove(BackupSection.SETTINGS)
sections.remove(BackupSection.SETTINGS_READER_GRID)
runBlocking { runBlocking {
repository.restoreBackup(input, sections, null) repository.restoreBackup(input, EnumSet.allOf(BackupSection::class.java), null)
} }
} }
} }

@ -15,16 +15,13 @@ enum class BackupSection(
SETTINGS_READER_GRID("reader_grid"), SETTINGS_READER_GRID("reader_grid"),
BOOKMARKS("bookmarks"), BOOKMARKS("bookmarks"),
SOURCES("sources"), SOURCES("sources"),
SCROBBLING("scrobbling"),
STATS("statistics"),
SAVED_FILTERS("saved_filters"),
; ;
companion object { companion object {
fun of(entry: ZipEntry): BackupSection? { fun of(entry: ZipEntry): BackupSection? {
val name = entry.name.lowercase(Locale.ROOT) val name = entry.name.lowercase(Locale.ROOT)
return entries.find { x -> x.entryName == name } return entries.first { x -> x.entryName == name }
} }
} }
} }

@ -36,7 +36,7 @@ class TelegramBackupUploader @Inject constructor(
suspend fun uploadBackup(file: File) { suspend fun uploadBackup(file: File) {
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull()) val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
val multipartBody = MultipartBody.Builder() val multipartBody = MultipartBody.Builder()
.setType(MultipartBody.FORM) .setType(MultipartBody.Companion.FORM)
.addFormDataPart("chat_id", requireChatId()) .addFormDataPart("chat_id", requireChatId())
.addFormDataPart("document", file.name, requestBody) .addFormDataPart("document", file.name, requestBody)
.build() .build()

@ -23,9 +23,6 @@ data class BackupSectionModel(
BackupSection.SETTINGS_READER_GRID -> R.string.reader_actions BackupSection.SETTINGS_READER_GRID -> R.string.reader_actions
BackupSection.BOOKMARKS -> R.string.bookmarks BackupSection.BOOKMARKS -> R.string.bookmarks
BackupSection.SOURCES -> R.string.remote_sources BackupSection.SOURCES -> R.string.remote_sources
BackupSection.SCROBBLING -> R.string.tracking
BackupSection.STATS -> R.string.statistics
BackupSection.SAVED_FILTERS -> R.string.saved_filters
} }
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {

@ -8,11 +8,11 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.hilt.work.HiltWorkerFactory import androidx.hilt.work.HiltWorkerFactory
import androidx.room.InvalidationTracker import androidx.room.InvalidationTracker
import androidx.work.Configuration import androidx.work.Configuration
import androidx.work.WorkManager
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.internal.platform.PlatformRegistry
import org.acra.ACRA import org.acra.ACRA
import org.acra.ReportField import org.acra.ReportField
import org.acra.config.dialog import org.acra.config.dialog
@ -27,6 +27,7 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.os.AppValidator import org.koitharu.kotatsu.core.os.AppValidator
import org.koitharu.kotatsu.core.os.RomCompat import org.koitharu.kotatsu.core.os.RomCompat
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
@ -61,6 +62,9 @@ open class BaseApp : Application(), Configuration.Provider {
@Inject @Inject
lateinit var workScheduleManager: WorkScheduleManager lateinit var workScheduleManager: WorkScheduleManager
@Inject
lateinit var workManagerProvider: Provider<WorkManager>
@Inject @Inject
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex> lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>
@ -75,7 +79,6 @@ open class BaseApp : Application(), Configuration.Provider {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
PlatformRegistry.applicationContext = this // TODO replace with OkHttp.initialize
if (ACRA.isACRASenderServiceProcess()) { if (ACRA.isACRASenderServiceProcess()) {
return return
} }
@ -94,6 +97,7 @@ open class BaseApp : Application(), Configuration.Provider {
localStorageChanges.collect(localMangaIndexProvider.get()) localStorageChanges.collect(localMangaIndexProvider.get())
} }
workScheduleManager.init() workScheduleManager.init()
WorkServiceStopHelper(workManagerProvider).setup()
} }
override fun attachBaseContext(base: Context) { override fun attachBaseContext(base: Context) {

@ -34,9 +34,6 @@ abstract class MangaDao {
@Query("SELECT author FROM manga WHERE author LIKE :query GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit") @Query("SELECT author FROM manga WHERE author LIKE :query GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit")
abstract suspend fun findAuthors(query: String, limit: Int): List<String> abstract suspend fun findAuthors(query: String, limit: Int): List<String>
@Query("SELECT author FROM manga WHERE manga.source = :source AND author IS NOT NULL AND author != '' GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit")
abstract suspend fun findAuthorsBySource(source: String, limit: Int): List<String>
@Transaction @Transaction
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit") @Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags> abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>

@ -1,10 +0,0 @@
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)

@ -7,7 +7,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.annotation.CheckResult
import androidx.annotation.RequiresPermission import androidx.annotation.RequiresPermission
import androidx.collection.MutableScatterMap import androidx.collection.MutableScatterMap
import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationChannelCompat
@ -44,7 +43,6 @@ import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.network.webview.WebViewExecutor
import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
@ -67,13 +65,11 @@ class CaptchaHandler @Inject constructor(
@LocalizedAppContext private val context: Context, @LocalizedAppContext private val context: Context,
private val databaseProvider: Provider<MangaDatabase>, private val databaseProvider: Provider<MangaDatabase>,
private val coilProvider: Provider<ImageLoader>, private val coilProvider: Provider<ImageLoader>,
private val webViewExecutor: WebViewExecutor,
) : EventListener() { ) : EventListener() {
private val exceptionMap = MutableScatterMap<MangaSource, CloudFlareProtectedException>() private val exceptionMap = MutableScatterMap<MangaSource, CloudFlareProtectedException>()
private val mutex = Mutex() private val mutex = Mutex()
@CheckResult
suspend fun handle(exception: CloudFlareException): Boolean = handleException(exception.source, exception, true) suspend fun handle(exception: CloudFlareException): Boolean = handleException(exception.source, exception, true)
suspend fun discard(source: MangaSource) { suspend fun discard(source: MangaSource) {
@ -83,18 +79,10 @@ class CaptchaHandler @Inject constructor(
override fun onError(request: ImageRequest, result: ErrorResult) { override fun onError(request: ImageRequest, result: ErrorResult) {
super.onError(request, result) super.onError(request, result)
val e = result.throwable val e = result.throwable
if (e is CloudFlareException) { if (e is CloudFlareException && request.extras[ignoreCaptchaKey] != true) {
val scope = request.lifecycle?.coroutineScope ?: processLifecycleScope val scope = request.lifecycle?.coroutineScope ?: processLifecycleScope
scope.launch { scope.launch {
if ( handleException(e.source, e, true)
handleException(
source = e.source,
exception = e,
notify = request.extras[suppressCaptchaKey] != true,
)
) {
coilProvider.get().enqueue(request) // TODO check if ok
}
} }
} }
} }
@ -102,14 +90,11 @@ class CaptchaHandler @Inject constructor(
private suspend fun handleException( private suspend fun handleException(
source: MangaSource, source: MangaSource,
exception: CloudFlareException?, exception: CloudFlareException?,
notify: Boolean, notify: Boolean
): Boolean = withContext(Dispatchers.Default) { ): Boolean = withContext(Dispatchers.Default) {
if (source == UnknownMangaSource) { if (source == UnknownMangaSource) {
return@withContext false return@withContext false
} }
if (exception != null && webViewExecutor.tryResolveCaptcha(exception, RESOLVE_TIMEOUT)) {
return@withContext true
}
mutex.withLock { mutex.withLock {
var removedException: CloudFlareProtectedException? = null var removedException: CloudFlareProtectedException? = null
if (exception is CloudFlareProtectedException) { if (exception is CloudFlareProtectedException) {
@ -134,7 +119,7 @@ class CaptchaHandler @Inject constructor(
notify(exceptions) notify(exceptions)
} }
} }
false true
} }
@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
@ -249,7 +234,7 @@ class CaptchaHandler @Inject constructor(
.data(source.faviconUri()) .data(source.faviconUri())
.allowHardware(false) .allowHardware(false)
.allowConversionToBitmap(true) .allowConversionToBitmap(true)
.suppressCaptchaErrors() .ignoreCaptchaErrors()
.mangaSourceExtra(source) .mangaSourceExtra(source)
.size(context.resources.getNotificationIconSize()) .size(context.resources.getNotificationIconSize())
.scale(Scale.FILL) .scale(Scale.FILL)
@ -275,11 +260,11 @@ class CaptchaHandler @Inject constructor(
companion object { companion object {
fun ImageRequest.Builder.suppressCaptchaErrors() = apply { fun ImageRequest.Builder.ignoreCaptchaErrors() = apply {
extras[suppressCaptchaKey] = true extras[ignoreCaptchaKey] = true
} }
private val suppressCaptchaKey = Extras.Key(false) val ignoreCaptchaKey = Extras.Key(false)
private const val CHANNEL_ID = "captcha" private const val CHANNEL_ID = "captcha"
private const val TAG = CHANNEL_ID private const val TAG = CHANNEL_ID
@ -287,6 +272,5 @@ class CaptchaHandler @Inject constructor(
private const val GROUP_NOTIFICATION_ID = 34 private const val GROUP_NOTIFICATION_ID = 34
private const val SETTINGS_ACTION_CODE = 3 private const val SETTINGS_ACTION_CODE = 3
private const val ACTION_DISCARD = "org.koitharu.kotatsu.CAPTCHA_DISCARD" private const val ACTION_DISCARD = "org.koitharu.kotatsu.CAPTCHA_DISCARD"
private const val RESOLVE_TIMEOUT = 20_000L
} }
} }

@ -8,15 +8,13 @@ import androidx.collection.MutableScatterMap
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle import dagger.assisted.Assisted
import androidx.lifecycle.LifecycleOwner import dagger.assisted.AssistedFactory
import androidx.lifecycle.lifecycleScope import dagger.assisted.AssistedInject
import kotlinx.coroutines.async
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
@ -26,7 +24,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.util.ext.isHttpUrl import org.koitharu.kotatsu.core.util.ext.isHttpUrl
import org.koitharu.kotatsu.core.util.ext.restartApplication import org.koitharu.kotatsu.core.util.ext.restartApplication
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@ -35,15 +32,14 @@ import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredExcept
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import java.security.cert.CertPathValidatorException import java.security.cert.CertPathValidatorException
import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
import javax.net.ssl.SSLException import javax.net.ssl.SSLException
import kotlin.coroutines.Continuation import kotlin.coroutines.Continuation
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
class ExceptionResolver private constructor( class ExceptionResolver @AssistedInject constructor(
private val host: Host, @Assisted private val host: Host,
private val settings: AppSettings, private val settings: AppSettings,
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>, private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
) { ) {
@ -60,11 +56,10 @@ class ExceptionResolver private constructor(
} }
fun showErrorDetails(e: Throwable, url: String? = null) { fun showErrorDetails(e: Throwable, url: String? = null) {
host.router.showErrorDialog(e, url) host.router()?.showErrorDialog(e, url)
} }
suspend fun resolve(e: Throwable): Boolean = host.lifecycleScope.async { suspend fun resolve(e: Throwable): Boolean = when (e) {
when (e) {
is CloudFlareProtectedException -> resolveCF(e) is CloudFlareProtectedException -> resolveCF(e)
is AuthRequiredException -> resolveAuthException(e.source) is AuthRequiredException -> resolveAuthException(e.source)
is SSLException, is SSLException,
@ -76,7 +71,7 @@ class ExceptionResolver private constructor(
is InteractiveActionRequiredException -> resolveBrowserAction(e) is InteractiveActionRequiredException -> resolveBrowserAction(e)
is ProxyConfigException -> { is ProxyConfigException -> {
host.router.openProxySettings() host.router()?.openProxySettings()
false false
} }
@ -85,16 +80,6 @@ class ExceptionResolver private constructor(
false 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 -> { is UnsupportedSourceException -> {
e.manga?.let { openAlternatives(it) } e.manga?.let { openAlternatives(it) }
false false
@ -114,7 +99,6 @@ class ExceptionResolver private constructor(
else -> false else -> false
} }
}.await()
private suspend fun resolveBrowserAction( private suspend fun resolveBrowserAction(
e: InteractiveActionRequiredException e: InteractiveActionRequiredException
@ -134,11 +118,11 @@ class ExceptionResolver private constructor(
} }
private fun openInBrowser(url: String) { private fun openInBrowser(url: String) {
host.router.openBrowser(url, null, null) host.router()?.openBrowser(url, null, null)
} }
private fun openAlternatives(manga: Manga) { private fun openAlternatives(manga: Manga) {
host.router.openAlternatives(manga) host.router()?.openAlternatives(manga)
} }
private fun handleActivityResult(tag: String, result: Boolean) { private fun handleActivityResult(tag: String, result: Boolean) {
@ -146,7 +130,7 @@ class ExceptionResolver private constructor(
} }
private fun showSslErrorDialog() { private fun showSslErrorDialog() {
val ctx = host.context ?: return val ctx = host.getContext() ?: return
if (settings.isSSLBypassEnabled) { if (settings.isSSLBypassEnabled) {
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show() Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
return return
@ -163,65 +147,27 @@ class ExceptionResolver private constructor(
}.show() }.show()
} }
class Factory @Inject constructor( private inline fun Host.withContext(block: Context.() -> Unit) {
private val settings: AppSettings, getContext()?.apply(block)
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 { private fun Host.router(): AppRouter? = when (this) {
is FragmentActivity -> router
val context: Context? is Fragment -> router
else -> null
val router: AppRouter
val fragmentManager: FragmentManager
inline fun withContext(block: Context.() -> Unit) {
context?.apply(block)
} }
class ActivityHost(val activity: FragmentActivity) : Host, interface Host : ActivityResultCaller {
ActivityResultCaller by activity,
LifecycleOwner by activity {
override val context: Context
get() = activity
override val router: AppRouter fun getChildFragmentManager(): FragmentManager
get() = activity.router
override val fragmentManager: FragmentManager fun getContext(): Context?
get() = activity.supportFragmentManager
} }
class FragmentHost(val fragment: Fragment) : Host, @AssistedFactory
ActivityResultCaller by fragment { interface Factory {
override val context: Context?
get() = fragment.context
override val router: AppRouter
get() = fragment.router
override val fragmentManager: FragmentManager fun create(host: Host): ExceptionResolver
get() = fragment.childFragmentManager
override val lifecycle: Lifecycle
get() = fragment.viewLifecycleOwner.lifecycle
}
} }
companion object { companion object {
@ -241,12 +187,6 @@ class ExceptionResolver private constructor(
is InteractiveActionRequiredException -> R.string._continue 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 else -> 0
} }

@ -28,15 +28,11 @@ data object UnknownMangaSource : MangaSource {
override val name = "UNKNOWN" override val name = "UNKNOWN"
} }
data object TestMangaSource : MangaSource {
override val name = "TEST"
}
fun MangaSource(name: String?): MangaSource { fun MangaSource(name: String?): MangaSource {
when (name ?: return UnknownMangaSource) { when (name ?: return UnknownMangaSource) {
UnknownMangaSource.name -> return UnknownMangaSource UnknownMangaSource.name -> return UnknownMangaSource
LocalMangaSource.name -> return LocalMangaSource LocalMangaSource.name -> return LocalMangaSource
TestMangaSource.name -> return TestMangaSource
} }
if (name.startsWith("content:")) { if (name.startsWith("content:")) {
val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownMangaSource val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownMangaSource
@ -96,7 +92,6 @@ fun MangaSource.getSummary(context: Context): String? = when (val source = unwra
fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()) { fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()) {
is MangaParserSource -> source.title is MangaParserSource -> source.title
LocalMangaSource -> context.getString(R.string.local_storage) LocalMangaSource -> context.getString(R.string.local_storage)
TestMangaSource -> context.getString(R.string.test_parser)
is ExternalMangaSource -> source.resolveName(context) is ExternalMangaSource -> source.resolveName(context)
else -> context.getString(R.string.unknown) else -> context.getString(R.string.unknown)
} }

@ -1,20 +0,0 @@
package org.koitharu.kotatsu.core.model
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.serialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.koitharu.kotatsu.parsers.model.MangaSource
object MangaSourceSerializer : KSerializer<MangaSource> {
override val descriptor: SerialDescriptor = serialDescriptor<String>()
override fun serialize(
encoder: Encoder,
value: MangaSource
) = encoder.encodeString(value.name)
override fun deserialize(decoder: Decoder): MangaSource = MangaSource(decoder.decodeString())
}

@ -215,12 +215,6 @@ class AppRouter private constructor(
startActivity(browserIntent(contextOrNull() ?: return, url, source, title)) startActivity(browserIntent(contextOrNull() ?: return, url, source, title))
} }
fun openBrowser(manga: Manga) = openBrowser(
url = manga.publicUrl,
source = manga.source,
title = manga.title,
)
fun openColorFilterConfig(manga: Manga, page: MangaPage) { fun openColorFilterConfig(manga: Manga, page: MangaPage) {
startActivity( startActivity(
Intent(contextOrNull(), ColorFilterConfigActivity::class.java) Intent(contextOrNull(), ColorFilterConfigActivity::class.java)
@ -804,7 +798,7 @@ class AppRouter private constructor(
else -> true else -> true
} }
fun shortMangaUrl(mangaId: Long): Uri = Uri.Builder() fun shortMangaUrl(mangaId: Long) = Uri.Builder()
.scheme("kotatsu") .scheme("kotatsu")
.path("manga") .path("manga")
.appendQueryParameter("id", mangaId.toString()) .appendQueryParameter("id", mangaId.toString())

@ -5,6 +5,7 @@ import androidx.annotation.WorkerThread
import androidx.core.util.Predicate import androidx.core.util.Predicate
import okhttp3.Cookie import okhttp3.Cookie
import okhttp3.HttpUrl import okhttp3.HttpUrl
import org.koitharu.kotatsu.parsers.util.newBuilder
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine

@ -1,30 +0,0 @@
package org.koitharu.kotatsu.core.network.webview
import android.graphics.Bitmap
import android.webkit.WebView
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import kotlin.coroutines.Continuation
class CaptchaContinuationClient(
private val cookieJar: MutableCookieJar,
private val targetUrl: String,
continuation: Continuation<Unit>,
) : ContinuationResumeWebViewClient(continuation) {
private val oldClearance = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl)
override fun onPageFinished(view: WebView?, url: String?) = Unit
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
checkClearance(view)
}
private fun checkClearance(view: WebView?) {
val clearance = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl)
if (clearance != null && clearance != oldClearance) {
resumeContinuation(view)
}
}
}

@ -2,22 +2,15 @@ package org.koitharu.kotatsu.core.network.webview
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import kotlinx.coroutines.CancellableContinuation
import kotlin.coroutines.Continuation import kotlin.coroutines.Continuation
import kotlin.coroutines.resume import kotlin.coroutines.resume
open class ContinuationResumeWebViewClient( class ContinuationResumeWebViewClient(
private val continuation: Continuation<Unit>, private val continuation: Continuation<Unit>,
) : WebViewClient() { ) : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) { override fun onPageFinished(view: WebView?, url: String?) {
resumeContinuation(view)
}
protected fun resumeContinuation(view: WebView?) {
if (continuation !is CancellableContinuation || continuation.isActive) {
view?.webViewClient = WebViewClient() // reset to default view?.webViewClient = WebViewClient() // reset to default
continuation.resume(Unit) continuation.resume(Unit)
} }
}
} }

@ -1,60 +1,34 @@
package org.koitharu.kotatsu.core.network.webview package org.koitharu.kotatsu.core.network.webview
import android.content.Context import android.content.Context
import android.util.AndroidRuntimeException
import android.webkit.WebSettings
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.annotation.MainThread import androidx.annotation.MainThread
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.koitharu.kotatsu.core.exceptions.CloudFlareException
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.util.ext.configureForParser import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
@Singleton @Singleton
class WebViewExecutor @Inject constructor( class WebViewExecutor @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context
private val proxyProvider: ProxyProvider,
private val cookieJar: MutableCookieJar,
private val mangaRepositoryFactoryProvider: Provider<MangaRepository.Factory>,
) { ) {
private var webViewCached: WeakReference<WebView>? = null private var webViewCached: WeakReference<WebView>? = null
private val mutex = Mutex() private val mutex = Mutex()
val defaultUserAgent: String? by lazy {
try {
WebSettings.getDefaultUserAgent(context)
} catch (e: AndroidRuntimeException) {
e.printStackTraceDebug()
// Probably WebView is not available
null
}
}
suspend fun evaluateJs(baseUrl: String?, script: String): String? = mutex.withLock { suspend fun evaluateJs(baseUrl: String?, script: String): String? = mutex.withLock {
withContext(Dispatchers.Main.immediate) { withContext(Dispatchers.Main.immediate) {
val webView = obtainWebView() val webView = obtainWebView()
try {
if (!baseUrl.isNullOrEmpty()) { if (!baseUrl.isNullOrEmpty()) {
suspendCoroutine { cont -> suspendCoroutine { cont ->
webView.webViewClient = ContinuationResumeWebViewClient(cont) webView.webViewClient = ContinuationResumeWebViewClient(cont)
@ -66,69 +40,19 @@ class WebViewExecutor @Inject constructor(
cont.resume(result?.takeUnless { it == "null" }) cont.resume(result?.takeUnless { it == "null" })
} }
} }
} finally {
webView.reset()
}
} }
} }
suspend fun tryResolveCaptcha(exception: CloudFlareException, timeout: Long): Boolean = mutex.withLock { @MainThread
runCatchingCancellable { fun getDefaultUserAgent() = runCatching {
withContext(Dispatchers.Main.immediate) { obtainWebView().settings.userAgentString.sanitizeHeaderValue().trim().nullIfEmpty()
val webView = obtainWebView()
try {
exception.source.getUserAgent()?.let {
webView.settings.userAgentString = it
}
withTimeout(timeout) {
suspendCancellableCoroutine { cont ->
webView.webViewClient = CaptchaContinuationClient(
cookieJar = cookieJar,
targetUrl = exception.url,
continuation = cont,
)
webView.loadUrl(exception.url)
}
}
} finally {
webView.reset()
}
}
}.onFailure { e -> }.onFailure { e ->
exception.addSuppressed(e)
e.printStackTraceDebug() e.printStackTraceDebug()
}.isSuccess }.getOrNull()
}
private suspend fun obtainWebView(): WebView { @MainThread
webViewCached?.get()?.let { private fun obtainWebView(): WebView = webViewCached?.get() ?: WebView(context).also {
return it
}
return withContext(Dispatchers.Main.immediate) {
webViewCached?.get()?.let {
return@withContext it
}
WebView(context).also {
it.configureForParser(null) it.configureForParser(null)
webViewCached = WeakReference(it) webViewCached = WeakReference(it)
proxyProvider.applyWebViewConfig()
it.onResume()
it.resumeTimers()
}
}
}
private fun MangaSource.getUserAgent(): String? {
val repository = mangaRepositoryFactoryProvider.get().create(this) as? ParserMangaRepository
return repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
}
@MainThread
private fun WebView.reset() {
stopLoading()
webViewClient = WebViewClient()
settings.userAgentString = defaultUserAgent
loadDataWithBaseURL(null, " ", "text/html", null, null)
clearHistory()
} }
} }

@ -80,7 +80,12 @@ class NetworkState(
if (settings.isOfflineCheckDisabled) { if (settings.isOfflineCheckDisabled) {
return true return true
} }
return activeNetwork?.let { isOnline(it) } == true return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
activeNetwork?.let { isOnline(it) } == true
} else {
@Suppress("DEPRECATION")
activeNetworkInfo?.isConnected == true
}
} }
private fun ConnectivityManager.isOnline(network: Network): Boolean { private fun ConnectivityManager.isOnline(network: Network): Boolean {

@ -0,0 +1,46 @@
package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.AbstractMangaParser
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet
/**
* This parser is just for parser development, it should not be used in releases
*/
class DummyParser(context: MangaLoaderContext) : AbstractMangaParser(context, MangaParserSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("localhost")
override val availableSortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)
override val filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities()
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
override suspend fun getList(
offset: Int,
order: SortOrder,
filter: MangaListFilter
): List<Manga> = stub(null)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
private fun stub(manga: Manga?): Nothing {
throw UnsupportedSourceException("Usage of Dummy parser", manga)
}
}

@ -11,7 +11,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet import java.util.EnumSet
open class EmptyMangaRepository(override val source: MangaSource) : MangaRepository { class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java) get() = EnumSet.allOf(SortOrder::class.java)

@ -9,8 +9,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
import org.koitharu.kotatsu.core.db.TABLE_PREFERENCES import org.koitharu.kotatsu.core.db.TABLE_PREFERENCES
import org.koitharu.kotatsu.core.db.entity.ContentRating import org.koitharu.kotatsu.core.db.entity.ContentRating
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
@ -191,11 +189,6 @@ class MangaDataRepository @Inject constructor(
emitInitialState = emitInitialState, emitInitialState = emitInitialState,
) )
fun observeFavoritesTrigger(emitInitialState: Boolean) = db.invalidationTracker.createFlow(
tables = arrayOf(TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES),
emitInitialState = emitInitialState,
)
private suspend fun Manga.withCachedChaptersIfNeeded(flag: Boolean): Manga = if (flag && !isLocal && chapters.isNullOrEmpty()) { private suspend fun Manga.withCachedChaptersIfNeeded(flag: Boolean): Manga = if (flag && !isLocal && chapters.isNullOrEmpty()) {
val cachedChapters = db.getChaptersDao().findAll(id) val cachedChapters = db.getChaptersDao().findAll(id)
if (cachedChapters.isEmpty()) { if (cachedChapters.isEmpty()) {

@ -5,6 +5,8 @@ import android.content.Context
import android.util.Base64 import android.util.Base64
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -31,6 +33,7 @@ import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.EmptyCoroutineContext
@Singleton @Singleton
class MangaLoaderContextImpl @Inject constructor( class MangaLoaderContextImpl @Inject constructor(
@ -40,6 +43,7 @@ class MangaLoaderContextImpl @Inject constructor(
private val webViewExecutor: WebViewExecutor, private val webViewExecutor: WebViewExecutor,
) : MangaLoaderContext() { ) : MangaLoaderContext() {
private val webViewUserAgent by lazy { obtainWebViewUserAgent() }
private val jsTimeout = TimeUnit.SECONDS.toMillis(4) private val jsTimeout = TimeUnit.SECONDS.toMillis(4)
@Deprecated("Provide a base url") @Deprecated("Provide a base url")
@ -50,7 +54,7 @@ class MangaLoaderContextImpl @Inject constructor(
webViewExecutor.evaluateJs(baseUrl, script) webViewExecutor.evaluateJs(baseUrl, script)
} }
override fun getDefaultUserAgent(): String = webViewExecutor.defaultUserAgent ?: UserAgents.FIREFOX_MOBILE override fun getDefaultUserAgent(): String = webViewUserAgent
override fun getConfig(source: MangaSource): MangaSourceConfig { override fun getConfig(source: MangaSource): MangaSourceConfig {
return SourceSettings(androidContext, source) return SourceSettings(androidContext, source)
@ -87,4 +91,15 @@ class MangaLoaderContextImpl @Inject constructor(
} }
override fun createBitmap(width: Int, height: Int): Bitmap = BitmapWrapper.create(width, height) override fun createBitmap(width: Int, height: Int): Bitmap = BitmapWrapper.create(width, height)
private fun obtainWebViewUserAgent(): String {
val mainDispatcher = Dispatchers.Main.immediate
return if (!mainDispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
webViewExecutor.getDefaultUserAgent()
} else {
runBlocking(mainDispatcher) {
webViewExecutor.getDefaultUserAgent()
}
} ?: UserAgents.FIREFOX_MOBILE
}
} }

@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.model.MangaParserSource
fun MangaParser(source: MangaParserSource, loaderContext: MangaLoaderContext): MangaParser {
return when (source) {
MangaParserSource.DUMMY -> DummyParser(loaderContext)
else -> loaderContext.newParserInstance(source)
}
}

@ -7,7 +7,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.MangaSourceInfo import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.model.TestMangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
@ -86,16 +85,11 @@ interface MangaRepository {
private fun createRepository(source: MangaSource): MangaRepository? = when (source) { private fun createRepository(source: MangaSource): MangaRepository? = when (source) {
is MangaParserSource -> ParserMangaRepository( is MangaParserSource -> ParserMangaRepository(
parser = loaderContext.newParserInstance(source), parser = MangaParser(source, loaderContext),
cache = contentCache, cache = contentCache,
mirrorSwitcher = mirrorSwitcher, mirrorSwitcher = mirrorSwitcher,
) )
TestMangaSource -> TestMangaRepository(
loaderContext = loaderContext,
cache = contentCache,
)
is ExternalMangaSource -> if (source.isAvailable(context)) { is ExternalMangaSource -> if (source.isAvailable(context)) {
ExternalMangaRepository( ExternalMangaRepository(
contentResolver = context.contentResolver, contentResolver = context.contentResolver,

@ -19,7 +19,6 @@ import coil3.request.Options
import coil3.size.pxOrElse import coil3.size.pxOrElse
import coil3.toAndroidUri import coil3.toAndroidUri
import coil3.toBitmap import coil3.toBitmap
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okio.FileSystem import okio.FileSystem
@ -42,6 +41,7 @@ import org.koitharu.kotatsu.local.data.LocalStorageCache
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.coroutineContext
import coil3.Uri as CoilUri import coil3.Uri as CoilUri
class FaviconFetcher( class FaviconFetcher(
@ -88,7 +88,7 @@ class FaviconFetcher(
var favicons = repository.getFavicons() var favicons = repository.getFavicons()
var lastError: Exception? = null var lastError: Exception? = null
while (favicons.isNotEmpty()) { while (favicons.isNotEmpty()) {
currentCoroutineContext().ensureActive() coroutineContext.ensureActive()
val icon = favicons.find(sizePx) ?: throwNSEE(lastError) val icon = favicons.find(sizePx) ?: throwNSEE(lastError)
try { try {
val result = imageLoader.fetch(icon.url, options) val result = imageLoader.fetch(icon.url, options)

@ -138,15 +138,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false) get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) } set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
var isReaderDoubleOnFoldable: Boolean
get() = prefs.getBoolean(KEY_READER_DOUBLE_FOLDABLE, false)
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_FOLDABLE, value) }
@get:FloatRange(0.0, 1.0)
var readerDoublePagesSensitivity: Float
get() = prefs.getFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, 0.5f)
set(@FloatRange(0.0, 1.0) value) = prefs.edit { putFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, value) }
val readerScreenOrientation: Int val readerScreenOrientation: Int
get() = prefs.getString(KEY_READER_ORIENTATION, null)?.toIntOrNull() get() = prefs.getString(KEY_READER_ORIENTATION, null)?.toIntOrNull()
?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
@ -413,9 +404,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderBarTransparent: Boolean val isReaderBarTransparent: Boolean
get() = prefs.getBoolean(KEY_READER_BAR_TRANSPARENT, true) get() = prefs.getBoolean(KEY_READER_BAR_TRANSPARENT, true)
val isReaderChapterToastEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_CHAPTER_TOAST, true)
val isReaderKeepScreenOn: Boolean val isReaderKeepScreenOn: Boolean
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true) get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
@ -500,10 +488,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_WEBTOON_GAPS, false) get() = prefs.getBoolean(KEY_WEBTOON_GAPS, false)
set(value) = prefs.edit { putBoolean(KEY_WEBTOON_GAPS, value) } set(value) = prefs.edit { putBoolean(KEY_WEBTOON_GAPS, value) }
var isWebtoonPullGestureEnabled: Boolean
get() = prefs.getBoolean(KEY_WEBTOON_PULL_GESTURE, false)
set(value) = prefs.edit { putBoolean(KEY_WEBTOON_PULL_GESTURE, value) }
@get:FloatRange(from = 0.0, to = 0.5) @get:FloatRange(from = 0.0, to = 0.5)
val defaultWebtoonZoomOut: Float val defaultWebtoonZoomOut: Float
get() = prefs.getInt(KEY_WEBTOON_ZOOM_OUT, 0).coerceIn(0, 50) / 100f get() = prefs.getInt(KEY_WEBTOON_ZOOM_OUT, 0).coerceIn(0, 50) / 100f
@ -550,11 +534,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isPeriodicalBackupEnabled: Boolean val isPeriodicalBackupEnabled: Boolean
get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false) get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false)
val periodicalBackupFrequency: Float val periodicalBackupFrequency: Long
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toFloatOrNull() ?: 7f get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L
val periodicalBackupFrequencyMillis: Long val periodicalBackupFrequencyMillis: Long
get() = (TimeUnit.DAYS.toMillis(1) * periodicalBackupFrequency).toLong() get() = TimeUnit.DAYS.toMillis(periodicalBackupFrequency)
val periodicalBackupMaxCount: Int val periodicalBackupMaxCount: Int
get() = if (prefs.getBoolean(KEY_BACKUP_PERIODICAL_TRIM, true)) { get() = if (prefs.getBoolean(KEY_BACKUP_PERIODICAL_TRIM, true)) {
@ -685,8 +669,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_REMOTE_SOURCES = "remote_sources" const val KEY_REMOTE_SOURCES = "remote_sources"
const val KEY_LOCAL_STORAGE = "local_storage" const val KEY_LOCAL_STORAGE = "local_storage"
const val KEY_READER_DOUBLE_PAGES = "reader_double_pages" const val KEY_READER_DOUBLE_PAGES = "reader_double_pages"
const val KEY_READER_DOUBLE_PAGES_SENSITIVITY = "reader_double_pages_sensitivity_2"
const val KEY_READER_DOUBLE_FOLDABLE = "reader_double_foldable"
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons" const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr" const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
const val KEY_READER_NAVIGATION_INVERTED = "reader_navigation_inverted" const val KEY_READER_NAVIGATION_INVERTED = "reader_navigation_inverted"
@ -755,7 +737,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SYNC_SETTINGS = "sync_settings" const val KEY_SYNC_SETTINGS = "sync_settings"
const val KEY_READER_BAR = "reader_bar" const val KEY_READER_BAR = "reader_bar"
const val KEY_READER_BAR_TRANSPARENT = "reader_bar_transparent" const val KEY_READER_BAR_TRANSPARENT = "reader_bar_transparent"
const val KEY_READER_CHAPTER_TOAST = "reader_chapter_toast"
const val KEY_READER_BACKGROUND = "reader_background" const val KEY_READER_BACKGROUND = "reader_background"
const val KEY_READER_SCREEN_ON = "reader_screen_on" const val KEY_READER_SCREEN_ON = "reader_screen_on"
const val KEY_SHORTCUTS = "dynamic_shortcuts" const val KEY_SHORTCUTS = "dynamic_shortcuts"
@ -767,7 +748,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_WEBTOON_GAPS = "webtoon_gaps" const val KEY_WEBTOON_GAPS = "webtoon_gaps"
const val KEY_WEBTOON_ZOOM = "webtoon_zoom" const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
const val KEY_WEBTOON_ZOOM_OUT = "webtoon_zoom_out" const val KEY_WEBTOON_ZOOM_OUT = "webtoon_zoom_out"
const val KEY_WEBTOON_PULL_GESTURE = "webtoon_pull_gesture"
const val KEY_PREFETCH_CONTENT = "prefetch_content" const val KEY_PREFETCH_CONTENT = "prefetch_content"
const val KEY_APP_LOCALE = "app_locale" const val KEY_APP_LOCALE = "app_locale"
const val KEY_SOURCES_GRID = "sources_grid" const val KEY_SOURCES_GRID = "sources_grid"

@ -13,14 +13,10 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.settings.utils.validation.DomainValidator import org.koitharu.kotatsu.settings.utils.validation.DomainValidator
import java.io.File
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig { class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
private val prefs = context.getSharedPreferences( private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE)
source.name.replace(File.separatorChar, '$'),
Context.MODE_PRIVATE,
)
var defaultSortOrder: SortOrder? var defaultSortOrder: SortOrder?
get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java) get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java)

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

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

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

@ -0,0 +1,8 @@
package org.koitharu.kotatsu.core.ui
import android.view.View
fun interface OnContextClickListenerCompat {
fun onContextClick(v: View): Boolean
}

@ -2,17 +2,10 @@ package org.koitharu.kotatsu.core.ui.dialog
import android.content.Context import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.view.inputmethod.EditorInfo
import android.widget.ArrayAdapter
import android.widget.CompoundButton.OnCheckedChangeListener import android.widget.CompoundButton.OnCheckedChangeListener
import android.widget.EditText
import android.widget.FrameLayout
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.annotation.UiContext import androidx.annotation.UiContext
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -22,7 +15,6 @@ import com.hannesdorfmann.adapterdelegates4.AdapterDelegatesManager
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.DialogCheckboxBinding import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
import org.koitharu.kotatsu.databinding.ViewDialogAutocompleteBinding
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
inline fun buildAlertDialog( inline fun buildAlertDialog(
@ -74,51 +66,3 @@ fun <B : AlertDialog.Builder> B.setRecyclerViewList(adapter: RecyclerView.Adapte
recyclerView.adapter = adapter recyclerView.adapter = adapter
setView(recyclerView) setView(recyclerView)
} }
fun <B : AlertDialog.Builder> B.setEditText(
inputType: Int,
singleLine: Boolean,
): EditText {
val editText = AppCompatEditText(context)
editText.inputType = inputType
if (singleLine) {
editText.setSingleLine()
editText.imeOptions = EditorInfo.IME_ACTION_DONE
}
val layout = FrameLayout(context)
val lp = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
val horizontalMargin = context.resources.getDimensionPixelOffset(R.dimen.screen_padding)
lp.setMargins(
horizontalMargin,
context.resources.getDimensionPixelOffset(R.dimen.margin_small),
horizontalMargin,
0,
)
layout.addView(editText, lp)
setView(layout)
return editText
}
fun <B : AlertDialog.Builder> B.setEditText(
entries: List<CharSequence>,
inputType: Int,
singleLine: Boolean,
): EditText {
if (entries.isEmpty()) {
return setEditText(inputType, singleLine)
}
val binding = ViewDialogAutocompleteBinding.inflate(LayoutInflater.from(context))
binding.autoCompleteTextView.setAdapter(
ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, entries),
)
binding.dropdown.setOnClickListener {
binding.autoCompleteTextView.showDropDown()
}
binding.autoCompleteTextView.inputType = inputType
if (singleLine) {
binding.autoCompleteTextView.setSingleLine()
binding.autoCompleteTextView.imeOptions = EditorInfo.IME_ACTION_DONE
}
setView(binding.root)
return binding.autoCompleteTextView
}

@ -10,7 +10,7 @@ import coil3.asImage
import coil3.request.Disposable import coil3.request.Disposable
import coil3.request.ImageRequest import coil3.request.ImageRequest
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler.Companion.suppressCaptchaErrors import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler.Companion.ignoreCaptchaErrors
import org.koitharu.kotatsu.core.image.CoilImageView import org.koitharu.kotatsu.core.image.CoilImageView
import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
@ -57,7 +57,7 @@ class FaviconView @JvmOverloads constructor(
.fallback(fallbackFactory) .fallback(fallbackFactory)
.placeholder(placeholderFactory) .placeholder(placeholderFactory)
.mangaSourceExtra(mangaSource) .mangaSourceExtra(mangaSource)
.suppressCaptchaErrors() .ignoreCaptchaErrors()
.build(), .build(),
) )
} }

@ -2,16 +2,17 @@ package org.koitharu.kotatsu.core.ui.list
import android.view.View import android.view.View
import android.view.View.OnClickListener import android.view.View.OnClickListener
import android.view.View.OnContextClickListener
import android.view.View.OnLongClickListener import android.view.View.OnLongClickListener
import androidx.core.util.Function import androidx.core.util.Function
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
class AdapterDelegateClickListenerAdapter<I, O>( class AdapterDelegateClickListenerAdapter<I, O>(
private val adapterDelegate: AdapterDelegateViewBindingViewHolder<out I, *>, private val adapterDelegate: AdapterDelegateViewBindingViewHolder<out I, *>,
private val clickListener: OnListItemClickListener<O>, private val clickListener: OnListItemClickListener<O>,
private val itemMapper: Function<I, O>, private val itemMapper: Function<I, O>,
) : OnClickListener, OnLongClickListener, OnContextClickListener { ) : OnClickListener, OnLongClickListener, OnContextClickListenerCompat {
override fun onClick(v: View) { override fun onClick(v: View) {
clickListener.onItemClick(mappedItem(), v) clickListener.onItemClick(mappedItem(), v)
@ -32,7 +33,7 @@ class AdapterDelegateClickListenerAdapter<I, O>(
fun attach(itemView: View) { fun attach(itemView: View) {
itemView.setOnClickListener(this) itemView.setOnClickListener(this)
itemView.setOnLongClickListener(this) itemView.setOnLongClickListener(this)
itemView.setOnContextClickListener(this) itemView.setOnContextClickListenerCompat(this)
} }
companion object { companion object {

@ -186,7 +186,6 @@ class ListSelectionController(
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_CREATE) { if (event == Lifecycle.Event.ON_CREATE) {
source.lifecycle.removeObserver(this)
val registry = registryOwner.savedStateRegistry val registry = registryOwner.savedStateRegistry
registry.registerSavedStateProvider(PROVIDER_NAME, this@ListSelectionController) registry.registerSavedStateProvider(PROVIDER_NAME, this@ListSelectionController)
val state = registry.consumeRestoredStateForKey(PROVIDER_NAME) val state = registry.consumeRestoredStateForKey(PROVIDER_NAME)

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

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

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.ui.util package org.koitharu.kotatsu.core.ui.util
import android.graphics.Color import android.graphics.Color
import android.os.Build
import android.view.ViewGroup import android.view.ViewGroup
import android.view.Window import android.view.Window
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
@ -13,6 +14,7 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@ -35,10 +37,14 @@ class ActionModeDelegate : OnBackPressedCallback(false) {
listeners?.forEach { it.onActionModeStarted(mode) } listeners?.forEach { it.onActionModeStarted(mode) }
if (window != null) { if (window != null) {
val ctx = window.context val ctx = window.context
val actionModeColor = ColorUtils.compositeColors( val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ColorUtils.compositeColors(
ContextCompat.getColor(ctx, materialR.color.m3_appbar_overlay_color), ContextCompat.getColor(ctx, materialR.color.m3_appbar_overlay_color),
ctx.getThemeColor(materialR.attr.colorSurface), ctx.getThemeColor(materialR.attr.colorSurface),
) )
} else {
ContextCompat.getColor(ctx, R.color.kotatsu_surface)
}
defaultStatusBarColor = window.statusBarColor defaultStatusBarColor = window.statusBarColor
window.statusBarColor = actionModeColor window.statusBarColor = actionModeColor
val insets = ViewCompat.getRootWindowInsets(window.decorView) val insets = ViewCompat.getRootWindowInsets(window.decorView)

@ -4,10 +4,12 @@ import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
class PopupMenuMediator( class PopupMenuMediator(
private val provider: MenuProvider, private val provider: MenuProvider,
) : View.OnLongClickListener, View.OnContextClickListener, PopupMenu.OnMenuItemClickListener, ) : View.OnLongClickListener, OnContextClickListenerCompat, PopupMenu.OnMenuItemClickListener,
PopupMenu.OnDismissListener { PopupMenu.OnDismissListener {
override fun onContextClick(v: View): Boolean = onLongClick(v) override fun onContextClick(v: View): Boolean = onLongClick(v)
@ -35,6 +37,6 @@ class PopupMenuMediator(
fun attach(view: View) { fun attach(view: View) {
view.setOnLongClickListener(this) view.setOnLongClickListener(this)
view.setOnContextClickListener(this) view.setOnContextClickListenerCompat(this)
} }
} }

@ -56,11 +56,6 @@ class ChipsView @JvmOverloads constructor(
val data = it.tag val data = it.tag
onChipCloseClickListener?.onChipCloseClick(chip, data) ?: onChipClickListener?.onChipClick(chip, data) onChipCloseClickListener?.onChipCloseClick(chip, data) ?: onChipClickListener?.onChipClick(chip, data)
} }
private val chipOnLongClickListener = OnLongClickListener {
val chip = it as Chip
val data = it.tag
onChipLongClickListener?.onChipLongClick(chip, data) ?: false
}
private val chipStyle: Int private val chipStyle: Int
private val iconsVisible: Boolean private val iconsVisible: Boolean
var onChipClickListener: OnChipClickListener? = null var onChipClickListener: OnChipClickListener? = null
@ -71,8 +66,6 @@ class ChipsView @JvmOverloads constructor(
} }
var onChipCloseClickListener: OnChipCloseClickListener? = null var onChipCloseClickListener: OnChipCloseClickListener? = null
var onChipLongClickListener: OnChipLongClickListener? = null
init { init {
val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0) val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0)
chipStyle = ta.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip) chipStyle = ta.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip)
@ -152,7 +145,6 @@ class ChipsView @JvmOverloads constructor(
setOnCloseIconClickListener(chipOnCloseListener) setOnCloseIconClickListener(chipOnCloseListener)
setEnsureMinTouchTargetSize(false) setEnsureMinTouchTargetSize(false)
setOnClickListener(chipOnClickListener) setOnClickListener(chipOnClickListener)
setOnLongClickListener(chipOnLongClickListener)
isElegantTextHeight = false isElegantTextHeight = false
} }
@ -284,9 +276,4 @@ class ChipsView @JvmOverloads constructor(
fun onChipCloseClick(chip: Chip, data: Any?) fun onChipCloseClick(chip: Chip, data: Any?)
} }
fun interface OnChipLongClickListener {
fun onChipLongClick(chip: Chip, data: Any?): Boolean
}
} }

@ -22,7 +22,7 @@ open class StackLayout @JvmOverloads constructor(
val h = b - t - paddingTop - paddingBottom val h = b - t - paddingTop - paddingBottom
visibleChildren.clear() visibleChildren.clear()
children.filterNotTo(visibleChildren) { it.isGone } children.filterNotTo(visibleChildren) { it.isGone }
if (w <= 0 || h <= 0 || visibleChildren.isEmpty()) { if (w <= 0 || h <= 0 || visibleChildren.isEmpty) {
return return
} }
val xStep = w / (visibleChildren.size + 1) val xStep = w / (visibleChildren.size + 1)

@ -1,22 +0,0 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.widget.FrameLayout
class TouchBlockLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
var isTouchEventsAllowed = true
override fun onInterceptTouchEvent(
ev: MotionEvent?
): Boolean = if (isTouchEventsAllowed) {
super.onInterceptTouchEvent(ev)
} else {
true
}
}

@ -0,0 +1,46 @@
package org.koitharu.kotatsu.core.util
import android.annotation.SuppressLint
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import androidx.work.impl.foreground.SystemForegroundService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import javax.inject.Provider
/**
* Workaround for issue
* https://issuetracker.google.com/issues/270245927
* https://issuetracker.google.com/issues/280504155
*/
class WorkServiceStopHelper(
private val workManagerProvider: Provider<WorkManager>,
) {
fun setup() {
processLifecycleScope.launch(Dispatchers.Default) {
workManagerProvider.get()
.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING))
.map { it.isEmpty() }
.distinctUntilChanged()
.collectLatest {
if (it) {
delay(1_000)
stopWorkerService()
}
}
}
}
@SuppressLint("RestrictedApi")
private fun stopWorkerService() {
SystemForegroundService.getInstance()?.stop()
}
}

@ -22,7 +22,6 @@ import org.koitharu.kotatsu.core.exceptions.CaughtException
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
@ -104,7 +103,6 @@ private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = w
is AccessDeniedException -> resources.getString(R.string.no_access_to_file) is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
is NonFileUriException -> resources.getString(R.string.error_non_file_uri) is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
is EmptyHistoryException -> resources.getString(R.string.history_is_empty) is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
is EmptyMangaException -> reason?.let { resources.getString(it.msgResId) } ?: cause?.getDisplayMessage(resources)
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration) is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
is SyncApiException, is SyncApiException,
is ContentUnavailableException -> message is ContentUnavailableException -> message
@ -169,8 +167,6 @@ fun Throwable.getCauseUrl(): String? = when (this) {
is CloudFlareProtectedException -> url is CloudFlareProtectedException -> url
is InteractiveActionRequiredException -> url is InteractiveActionRequiredException -> url
is HttpStatusException -> 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() is HttpException -> (response.delegate as? Response)?.request?.url?.toString()
else -> null else -> null
} }

@ -28,6 +28,7 @@ import com.google.android.material.progressindicator.BaseProgressIndicator
import com.google.android.material.slider.RangeSlider import com.google.android.material.slider.RangeSlider
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
import kotlin.math.roundToInt import kotlin.math.roundToInt
fun View.hasGlobalPoint(x: Int, y: Int): Boolean { fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
@ -168,6 +169,12 @@ fun BaseProgressIndicator<*>.showOrHide(value: Boolean) {
} }
} }
fun View.setOnContextClickListenerCompat(listener: OnContextClickListenerCompat) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setOnContextClickListener(listener::onContextClick)
}
}
fun View.setTooltipCompat(tooltip: CharSequence?) { fun View.setTooltipCompat(tooltip: CharSequence?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
tooltipText = tooltip tooltipText = tooltip

@ -7,7 +7,6 @@ import org.koitharu.kotatsu.core.ui.model.MangaOverride
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.reader.data.filterChapters import org.koitharu.kotatsu.reader.data.filterChapters
@ -51,9 +50,6 @@ data class MangaDetails(
.ifNullOrEmpty { localManga?.manga?.coverUrl } .ifNullOrEmpty { localManga?.manga?.coverUrl }
?.nullIfEmpty() ?.nullIfEmpty()
val isRestricted: Boolean
get() = manga.state == MangaState.RESTRICTED
private val mergedManga by lazy { private val mergedManga by lazy {
if (localManga == null) { if (localManga == null) {
// fast path // fast path

@ -1,11 +1,13 @@
package org.koitharu.kotatsu.details.domain package org.koitharu.kotatsu.details.domain
import android.util.Log
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings

@ -34,17 +34,12 @@ class ProgressUpdateUseCase @Inject constructor(
} }
val chapter = details.findChapterById(history.chapterId) ?: return PROGRESS_NONE val chapter = details.findChapterById(history.chapterId) ?: return PROGRESS_NONE
val chapters = details.getChapters(chapter.branch) val chapters = details.getChapters(chapter.branch)
val chapterRepo = if (repo.source == chapter.source) {
repo
} else {
mangaRepositoryFactory.create(chapter.source)
}
val chaptersCount = chapters.size val chaptersCount = chapters.size
if (chaptersCount == 0) { if (chaptersCount == 0) {
return PROGRESS_NONE return PROGRESS_NONE
} }
val chapterIndex = chapters.indexOfFirst { x -> x.id == history.chapterId } val chapterIndex = chapters.indexOfFirst { x -> x.id == history.chapterId }
val pagesCount = chapterRepo.getPages(chapter).size val pagesCount = repo.getPages(chapter).size
if (pagesCount == 0) { if (pagesCount == 0) {
return PROGRESS_NONE return PROGRESS_NONE
} }

@ -27,7 +27,7 @@ class ReadingTimeUseCase @Inject constructor(
// Impossible task, I guess. Good luck on this. // Impossible task, I guess. Good luck on this.
var averageTimeSec: Int = 20 /* pages */ * getSecondsPerPage(manga.id) * chapters.size var averageTimeSec: Int = 20 /* pages */ * getSecondsPerPage(manga.id) * chapters.size
if (isOnHistoryBranch) { if (isOnHistoryBranch) {
averageTimeSec = (averageTimeSec * (1f - history.percent)).roundToInt() averageTimeSec = (averageTimeSec * (1f - checkNotNull(history).percent)).roundToInt()
} }
if (averageTimeSec < 60) { if (averageTimeSec < 60) {
return null return null

@ -2,6 +2,7 @@ package org.koitharu.kotatsu.details.ui
import android.app.assist.AssistContent import android.app.assist.AssistContent
import android.content.Context import android.content.Context
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.text.SpannedString import android.text.SpannedString
import android.view.Gravity import android.view.Gravity
@ -208,8 +209,10 @@ class DetailsActivity :
override fun onProvideAssistContent(outContent: AssistContent) { override fun onProvideAssistContent(outContent: AssistContent) {
super.onProvideAssistContent(outContent) super.onProvideAssistContent(outContent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it } viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it }
} }
}
override fun isNsfwContent(): Flow<Boolean> = viewModel.manga.map { it?.contentRating == ContentRating.ADULT } override fun isNsfwContent(): Flow<Boolean> = viewModel.manga.map { it?.contentRating == ContentRating.ADULT }

@ -140,7 +140,6 @@ class DetailsViewModel @Inject constructor(
get() = scrobblers.any { it.isEnabled } get() = scrobblers.any { it.isEnabled }
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId) val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
.withErrorHandling()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val relatedManga: StateFlow<List<MangaListModel>> = manga.mapLatest { val relatedManga: StateFlow<List<MangaListModel>> = manga.mapLatest {

@ -99,11 +99,10 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(),
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets
override fun onStateChanged(sheet: View, newState: Int) { override fun onStateChanged(sheet: View, newState: Int) {
val binding = viewBinding ?: return
binding.layoutTouchBlock.isTouchEventsAllowed = dialog != null || newState != STATE_COLLAPSED
if (newState == STATE_DRAGGING || newState == STATE_SETTLING) { if (newState == STATE_DRAGGING || newState == STATE_SETTLING) {
return return
} }
val binding = viewBinding ?: return
val isActionModeStarted = actionModeDelegate?.isActionModeStarted == true val isActionModeStarted = actionModeDelegate?.isActionModeStarted == true
binding.toolbar.menuView?.isVisible = newState == STATE_EXPANDED && !isActionModeStarted binding.toolbar.menuView?.isVisible = newState == STATE_EXPANDED && !isActionModeStarted
binding.splitButtonRead.isVisible = newState != STATE_EXPANDED && !isActionModeStarted binding.splitButtonRead.isVisible = newState != STATE_EXPANDED && !isActionModeStarted

@ -3,7 +3,6 @@ package org.koitharu.kotatsu.details.ui.pager.chapters
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.widget.Toast
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
@ -12,7 +11,6 @@ import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.list.BaseListSelectionCallback import org.koitharu.kotatsu.core.ui.list.BaseListSelectionCallback
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toCollection import org.koitharu.kotatsu.core.util.ext.toCollection
import org.koitharu.kotatsu.core.util.ext.toSet import org.koitharu.kotatsu.core.util.ext.toSet
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
@ -80,20 +78,11 @@ class ChaptersSelectionCallback(
ids.size == manga.chapters?.size -> viewModel.deleteLocal() ids.size == manga.chapters?.size -> viewModel.deleteLocal()
else -> { else -> {
LocalChaptersRemoveService.start(recyclerView.context, manga, ids.toSet()) LocalChaptersRemoveService.start(recyclerView.context, manga, ids.toSet())
try {
Snackbar.make( Snackbar.make(
recyclerView, recyclerView,
R.string.chapters_will_removed_background, R.string.chapters_will_removed_background,
Snackbar.LENGTH_LONG, Snackbar.LENGTH_LONG,
).show() ).show()
} catch (e: IllegalArgumentException) {
e.printStackTraceDebug()
Toast.makeText(
recyclerView.context,
R.string.chapters_will_removed_background,
Toast.LENGTH_SHORT,
).show()
}
} }
} }
mode?.finish() mode?.finish()

@ -105,14 +105,7 @@ class PagesViewModel @Inject constructor(
chaptersLoader.peekChapter(it) != null chaptersLoader.peekChapter(it) != null
} ?: state.details.allChapters.firstOrNull()?.id ?: return } ?: state.details.allChapters.firstOrNull()?.id ?: return
if (!chaptersLoader.hasPages(initialChapterId)) { if (!chaptersLoader.hasPages(initialChapterId)) {
var hasPages = chaptersLoader.loadSingleChapter(initialChapterId) chaptersLoader.loadSingleChapter(initialChapterId)
while (!hasPages) {
if (chaptersLoader.loadPrevNextChapter(state.details, initialChapterId, isNext = true)) {
hasPages = chaptersLoader.snapshot().isNotEmpty()
} else {
break
}
}
} }
updateList(state.readerState) updateList(state.readerState)
} }

@ -7,7 +7,6 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@ -26,8 +25,6 @@ import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject import javax.inject.Inject
@ -38,8 +35,7 @@ class RelatedListViewModel @Inject constructor(
settings: AppSettings, settings: AppSettings,
private val mangaListMapper: MangaListMapper, private val mangaListMapper: MangaListMapper,
mangaDataRepository: MangaDataRepository, mangaDataRepository: MangaDataRepository,
@LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>, ) : MangaListViewModel(settings, mangaDataRepository) {
) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges) {
private val seed = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga private val seed = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga
private val repository = mangaRepositoryFactory.create(seed.source) private val repository = mangaRepositoryFactory.create(seed.source)

@ -202,7 +202,7 @@ class DownloadWorker @AssistedInject constructor(
?: error("Cannot obtain remote manga instance") ?: error("Cannot obtain remote manga instance")
} }
val repo = mangaRepositoryFactory.create(manga.source) val repo = mangaRepositoryFactory.create(manga.source)
val mangaDetails = if (manga.chapters.isNullOrEmpty() || manga.description.isNullOrEmpty()) repo.getDetails(manga) else manga val mangaDetails = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = LocalMangaOutput.getOrCreate( output = LocalMangaOutput.getOrCreate(
root = destination, root = destination,
manga = mangaDetails, manga = mangaDetails,

@ -53,9 +53,11 @@ class MangaSourcesRepository @Inject constructor(
get() = db.getSourcesDao() get() = db.getSourcesDao()
val allMangaSources: Set<MangaParserSource> = Collections.unmodifiableSet( val allMangaSources: Set<MangaParserSource> = Collections.unmodifiableSet(
EnumSet.noneOf<MangaParserSource>(MangaParserSource::class.java).also { EnumSet.allOf(MangaParserSource::class.java).apply {
MangaParserSource.entries.filterNotTo(it, MangaParserSource::isBroken) if (!BuildConfig.DEBUG) {
remove(MangaParserSource.DUMMY)
} }
},
) )
suspend fun getEnabledSources(): List<MangaSource> { suspend fun getEnabledSources(): List<MangaSource> {

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.explore.ui.adapter package org.koitharu.kotatsu.explore.ui.adapter
import android.view.View import android.view.View
import androidx.appcompat.widget.TooltipCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.text.bold import androidx.core.text.bold
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString

@ -40,9 +40,6 @@ import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga
import kotlinx.coroutines.flow.SharedFlow
private const val PAGE_SIZE = 16 private const val PAGE_SIZE = 16
@ -55,8 +52,7 @@ class FavouritesListViewModel @Inject constructor(
quickFilterFactory: FavoritesListQuickFilter.Factory, quickFilterFactory: FavoritesListQuickFilter.Factory,
settings: AppSettings, settings: AppSettings,
mangaDataRepository: MangaDataRepository, mangaDataRepository: MangaDataRepository,
@LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>, ) : MangaListViewModel(settings, mangaDataRepository), QuickFilterListener {
) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges), QuickFilterListener {
val categoryId: Long = savedStateHandle[AppRouter.KEY_ID] ?: NO_ID val categoryId: Long = savedStateHandle[AppRouter.KEY_ID] ?: NO_ID
private val quickFilter = quickFilterFactory.create(categoryId) private val quickFilter = quickFilterFactory.create(categoryId)

@ -1,161 +0,0 @@
package org.koitharu.kotatsu.filter.data
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.SetSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.descriptors.element
import kotlinx.serialization.encoding.CompositeDecoder
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.encoding.decodeStructure
import kotlinx.serialization.encoding.encodeStructure
import kotlinx.serialization.serializer
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.util.ext.toLocaleOrNull
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Demographic
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import java.util.Locale
object MangaListFilterSerializer : KSerializer<MangaListFilter> {
override val descriptor: SerialDescriptor =
buildClassSerialDescriptor(MangaListFilter::class.java.name) {
element<String?>("query", isOptional = true)
element(
elementName = "tags",
descriptor = SetSerializer(MangaTagSerializer).descriptor,
isOptional = true,
)
element(
elementName = "tagsExclude",
descriptor = SetSerializer(MangaTagSerializer).descriptor,
isOptional = true,
)
element<String?>("locale", isOptional = true)
element<String?>("originalLocale", isOptional = true)
element<Set<MangaState>>("states", isOptional = true)
element<Set<ContentRating>>("contentRating", isOptional = true)
element<Set<ContentType>>("types", isOptional = true)
element<Set<Demographic>>("demographics", isOptional = true)
element<Int>("year", isOptional = true)
element<Int>("yearFrom", isOptional = true)
element<Int>("yearTo", isOptional = true)
element<String?>("author", isOptional = true)
}
override fun serialize(
encoder: Encoder,
value: MangaListFilter
) = encoder.encodeStructure(descriptor) {
encodeNullableSerializableElement(descriptor, 0, String.serializer(), value.query)
encodeSerializableElement(descriptor, 1, SetSerializer(MangaTagSerializer), value.tags)
encodeSerializableElement(descriptor, 2, SetSerializer(MangaTagSerializer), value.tagsExclude)
encodeNullableSerializableElement(descriptor, 3, String.serializer(), value.locale?.toLanguageTag())
encodeNullableSerializableElement(descriptor, 4, String.serializer(), value.originalLocale?.toLanguageTag())
encodeSerializableElement(descriptor, 5, SetSerializer(serializer()), value.states)
encodeSerializableElement(descriptor, 6, SetSerializer(serializer()), value.contentRating)
encodeSerializableElement(descriptor, 7, SetSerializer(serializer()), value.types)
encodeSerializableElement(descriptor, 8, SetSerializer(serializer()), value.demographics)
encodeIntElement(descriptor, 9, value.year)
encodeIntElement(descriptor, 10, value.yearFrom)
encodeIntElement(descriptor, 11, value.yearTo)
encodeNullableSerializableElement(descriptor, 12, String.serializer(), value.author)
}
override fun deserialize(
decoder: Decoder
): MangaListFilter = decoder.decodeStructure(descriptor) {
var query: String? = MangaListFilter.EMPTY.query
var tags: Set<MangaTag> = MangaListFilter.EMPTY.tags
var tagsExclude: Set<MangaTag> = MangaListFilter.EMPTY.tagsExclude
var locale: Locale? = MangaListFilter.EMPTY.locale
var originalLocale: Locale? = MangaListFilter.EMPTY.originalLocale
var states: Set<MangaState> = MangaListFilter.EMPTY.states
var contentRating: Set<ContentRating> = MangaListFilter.EMPTY.contentRating
var types: Set<ContentType> = MangaListFilter.EMPTY.types
var demographics: Set<Demographic> = MangaListFilter.EMPTY.demographics
var year: Int = MangaListFilter.EMPTY.year
var yearFrom: Int = MangaListFilter.EMPTY.yearFrom
var yearTo: Int = MangaListFilter.EMPTY.yearTo
var author: String? = MangaListFilter.EMPTY.author
while (true) {
when (decodeElementIndex(descriptor)) {
0 -> query = decodeNullableSerializableElement(descriptor, 0, serializer<String>())
1 -> tags = decodeSerializableElement(descriptor, 1, SetSerializer(MangaTagSerializer))
2 -> tagsExclude = decodeSerializableElement(descriptor, 2, SetSerializer(MangaTagSerializer))
3 -> locale = decodeNullableSerializableElement(descriptor, 3, serializer<String>())?.toLocaleOrNull()
4 -> originalLocale =
decodeNullableSerializableElement(descriptor, 4, serializer<String>())?.toLocaleOrNull()
5 -> states = decodeSerializableElement(descriptor, 5, SetSerializer(serializer()))
6 -> contentRating = decodeSerializableElement(descriptor, 6, SetSerializer(serializer()))
7 -> types = decodeSerializableElement(descriptor, 7, SetSerializer(serializer()))
8 -> demographics = decodeSerializableElement(descriptor, 8, SetSerializer(serializer()))
9 -> year = decodeIntElement(descriptor, 9)
10 -> yearFrom = decodeIntElement(descriptor, 10)
11 -> yearTo = decodeIntElement(descriptor, 11)
12 -> author = decodeNullableSerializableElement(descriptor, 12, serializer<String>())
CompositeDecoder.DECODE_DONE -> break
}
}
MangaListFilter(
query = query,
tags = tags,
tagsExclude = tagsExclude,
locale = locale,
originalLocale = originalLocale,
states = states,
contentRating = contentRating,
types = types,
demographics = demographics,
year = year,
yearFrom = yearFrom,
yearTo = yearTo,
author = author,
)
}
private object MangaTagSerializer : KSerializer<MangaTag> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor(MangaTag::class.java.name) {
element<String>("title")
element<String>("key")
element<String>("source")
}
override fun serialize(encoder: Encoder, value: MangaTag) = encoder.encodeStructure(descriptor) {
encodeStringElement(descriptor, 0, value.title)
encodeStringElement(descriptor, 1, value.key)
encodeStringElement(descriptor, 2, value.source.name)
}
override fun deserialize(decoder: Decoder): MangaTag = decoder.decodeStructure(descriptor) {
var title: String? = null
var key: String? = null
var source: String? = null
while (true) {
when (decodeElementIndex(descriptor)) {
0 -> title = decodeStringElement(descriptor, 0)
1 -> key = decodeStringElement(descriptor, 1)
2 -> source = decodeStringElement(descriptor, 2)
CompositeDecoder.DECODE_DONE -> break
}
}
MangaTag(
title = title ?: error("Missing 'title' field"),
key = key ?: error("Missing 'key' field"),
source = MangaSource(source),
)
}
}
}

@ -1,30 +0,0 @@
package org.koitharu.kotatsu.filter.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
import org.koitharu.kotatsu.core.model.MangaSourceSerializer
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
@Serializable
@JsonIgnoreUnknownKeys
data class PersistableFilter(
@SerialName("name")
val name: String,
@Serializable(with = MangaSourceSerializer::class)
@SerialName("source")
val source: MangaSource,
@Serializable(with = MangaListFilterSerializer::class)
@SerialName("filter")
val filter: MangaListFilter,
) {
val id: Int
get() = name.hashCode()
companion object {
const val MAX_TITLE_LENGTH = 18
}
}

@ -1,118 +0,0 @@
package org.koitharu.kotatsu.filter.data
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.koitharu.kotatsu.core.util.ext.observeChanges
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.io.File
import javax.inject.Inject
@Reusable
class SavedFiltersRepository @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun observeAll(source: MangaSource): Flow<List<PersistableFilter>> = getPrefs(source).observeChanges()
.onStart { emit(null) }
.map {
getAll(source)
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
suspend fun getAll(source: MangaSource): List<PersistableFilter> = withContext(Dispatchers.Default) {
val prefs = getPrefs(source)
val keys = prefs.all.keys.filter { it.startsWith(FILTER_PREFIX) }
keys.mapNotNull { key ->
val value = prefs.getString(key, null) ?: return@mapNotNull null
try {
Json.decodeFromString(value)
} catch (e: SerializationException) {
e.printStackTraceDebug()
null
}
}
}
suspend fun save(
source: MangaSource,
name: String,
filter: MangaListFilter,
): PersistableFilter = withContext(Dispatchers.Default) {
val persistableFilter = PersistableFilter(
name = name,
source = source,
filter = filter,
)
persist(persistableFilter)
persistableFilter
}
suspend fun save(
filter: PersistableFilter,
) = withContext(Dispatchers.Default) {
persist(filter)
}
suspend fun rename(source: MangaSource, id: Int, newName: String) = withContext(Dispatchers.Default) {
val filter = load(source, id) ?: return@withContext
val newFilter = filter.copy(name = newName)
val prefs = getPrefs(source)
prefs.edit(commit = true) {
remove(key(id))
putString(key(newFilter.id), Json.encodeToString(newFilter))
}
newFilter
}
suspend fun delete(source: MangaSource, id: Int) = withContext(Dispatchers.Default) {
val prefs = getPrefs(source)
prefs.edit(commit = true) {
remove(key(id))
}
}
private fun persist(persistableFilter: PersistableFilter) {
val prefs = getPrefs(persistableFilter.source)
val json = Json.encodeToString(persistableFilter)
prefs.edit(commit = true) {
putString(key(persistableFilter.id), json)
}
}
private fun load(source: MangaSource, id: Int): PersistableFilter? {
val prefs = getPrefs(source)
val json = prefs.getString(key(id), null) ?: return null
return try {
Json.decodeFromString<PersistableFilter>(json)
} catch (e: SerializationException) {
e.printStackTraceDebug()
null
}
}
private fun getPrefs(source: MangaSource): SharedPreferences {
val key = source.name.replace(File.separatorChar, '$')
return context.getSharedPreferences(key, Context.MODE_PRIVATE)
}
private companion object {
const val FILTER_PREFIX = "__pf_"
fun key(id: Int) = FILTER_PREFIX + id
}
}

@ -17,7 +17,6 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
@ -26,8 +25,6 @@ import org.koitharu.kotatsu.core.util.ext.asFlow
import org.koitharu.kotatsu.core.util.ext.lifecycleScope import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
import org.koitharu.kotatsu.filter.data.PersistableFilter
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.filter.ui.tags.TagTitleComparator import org.koitharu.kotatsu.filter.ui.tags.TagTitleComparator
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
@ -54,7 +51,6 @@ class FilterCoordinator @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory, mangaRepositoryFactory: MangaRepository.Factory,
private val searchRepository: MangaSearchRepository, private val searchRepository: MangaSearchRepository,
private val savedFiltersRepository: SavedFiltersRepository,
lifecycle: ViewModelLifecycle, lifecycle: ViewModelLifecycle,
) { ) {
@ -67,7 +63,6 @@ class FilterCoordinator @Inject constructor(
private val availableSortOrders = repository.sortOrders private val availableSortOrders = repository.sortOrders
private val filterOptions = suspendLazy { repository.getFilterOptions() } private val filterOptions = suspendLazy { repository.getFilterOptions() }
val capabilities = repository.filterCapabilities val capabilities = repository.filterCapabilities
val mangaSource: MangaSource val mangaSource: MangaSource
@ -124,20 +119,6 @@ class FilterCoordinator @Inject constructor(
MutableStateFlow(FilterProperty.EMPTY) MutableStateFlow(FilterProperty.EMPTY)
} }
val authors: StateFlow<FilterProperty<String>> = if (capabilities.isAuthorSearchSupported) {
combine(
flow { emit(searchRepository.getAuthors(repository.source, TAGS_LIMIT)) },
currentListFilter.distinctUntilChangedBy { it.author },
) { available, selected ->
FilterProperty(
availableItems = available,
selectedItems = setOfNotNull(selected.author),
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
} else {
MutableStateFlow(FilterProperty.EMPTY)
}
val states: StateFlow<FilterProperty<MangaState>> = combine( val states: StateFlow<FilterProperty<MangaState>> = combine(
filterOptions.asFlow(), filterOptions.asFlow(),
currentListFilter.distinctUntilChangedBy { it.states }, currentListFilter.distinctUntilChangedBy { it.states },
@ -268,16 +249,6 @@ class FilterCoordinator @Inject constructor(
MutableStateFlow(FilterProperty.EMPTY) MutableStateFlow(FilterProperty.EMPTY)
} }
val savedFilters: StateFlow<FilterProperty<PersistableFilter>> = combine(
savedFiltersRepository.observeAll(repository.source),
currentListFilter,
) { available, applied ->
FilterProperty(
availableItems = available,
selectedItems = setOfNotNull(available.find { it.filter == applied }),
)
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.EMPTY)
fun reset() { fun reset() {
currentListFilter.value = MangaListFilter.EMPTY currentListFilter.value = MangaListFilter.EMPTY
} }
@ -306,24 +277,17 @@ class FilterCoordinator @Inject constructor(
author = null, author = null,
) )
} }
if (!capabilities.isSearchSupported && !newFilter.query.isNullOrEmpty()) {
newFilter = newFilter.copy(
query = null,
)
}
if (!newFilter.query.isNullOrEmpty() && !newFilter.hasNonSearchOptions() && !capabilities.isSearchWithFiltersSupported) { if (!newFilter.query.isNullOrEmpty() && !newFilter.hasNonSearchOptions() && !capabilities.isSearchWithFiltersSupported) {
newFilter = MangaListFilter(query = newFilter.query) newFilter = MangaListFilter(query = newFilter.query)
} }
set(newFilter) set(newFilter)
} }
fun saveCurrentFilter(name: String) = coroutineScope.launch {
savedFiltersRepository.save(repository.source, name, currentListFilter.value)
}
fun renameSavedFilter(id: Int, newName: String) = coroutineScope.launch {
savedFiltersRepository.rename(repository.source, id, newName)
}
fun deleteSavedFilter(id: Int) = coroutineScope.launch {
savedFiltersRepository.delete(repository.source, id)
}
fun setQuery(value: String?) { fun setQuery(value: String?) {
val newQuery = value?.trim()?.nullIfEmpty() val newQuery = value?.trim()?.nullIfEmpty()
currentListFilter.update { oldValue -> currentListFilter.update { oldValue ->

@ -16,7 +16,6 @@ import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding
import org.koitharu.kotatsu.filter.data.PersistableFilter
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
@ -55,12 +54,6 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
override fun onChipClick(chip: Chip, data: Any?) { override fun onChipClick(chip: Chip, data: Any?) {
when (data) { when (data) {
is MangaTag -> filter.toggleTag(data, !chip.isChecked) is MangaTag -> filter.toggleTag(data, !chip.isChecked)
is PersistableFilter -> if (chip.isChecked) {
filter.reset()
} else {
filter.setAdjusted(data.filter)
}
is String -> Unit is String -> Unit
null -> router.showTagsCatalogSheet(excludeMode = false) null -> router.showTagsCatalogSheet(excludeMode = false)
} }

@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.titleResId import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.filter.data.PersistableFilter
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
@ -22,15 +21,10 @@ class FilterHeaderProducer @Inject constructor(
) { ) {
fun observeHeader(filterCoordinator: FilterCoordinator): Flow<FilterHeaderModel> { fun observeHeader(filterCoordinator: FilterCoordinator): Flow<FilterHeaderModel> {
return combine( return combine(filterCoordinator.tags, filterCoordinator.observe()) { tags, snapshot ->
filterCoordinator.savedFilters,
filterCoordinator.tags,
filterCoordinator.observe(),
) { saved, tags, snapshot ->
val chipList = createChipsList( val chipList = createChipsList(
source = filterCoordinator.mangaSource, source = filterCoordinator.mangaSource,
capabilities = filterCoordinator.capabilities, capabilities = filterCoordinator.capabilities,
savedFilters = saved,
tagsProperty = tags, tagsProperty = tags,
snapshot = snapshot.listFilter, snapshot = snapshot.listFilter,
limit = 12, limit = 12,
@ -46,12 +40,11 @@ class FilterHeaderProducer @Inject constructor(
private suspend fun createChipsList( private suspend fun createChipsList(
source: MangaSource, source: MangaSource,
capabilities: MangaListFilterCapabilities, capabilities: MangaListFilterCapabilities,
savedFilters: FilterProperty<PersistableFilter>,
tagsProperty: FilterProperty<MangaTag>, tagsProperty: FilterProperty<MangaTag>,
snapshot: MangaListFilter, snapshot: MangaListFilter,
limit: Int, limit: Int,
): List<ChipsView.ChipModel> { ): List<ChipsView.ChipModel> {
val result = ArrayDeque<ChipsView.ChipModel>(savedFilters.availableItems.size + limit + 3) val result = ArrayDeque<ChipsView.ChipModel>(limit + 3)
if (snapshot.query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) { if (snapshot.query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) {
val selectedTags = tagsProperty.selectedItems.toMutableSet() val selectedTags = tagsProperty.selectedItems.toMutableSet()
var tags = if (selectedTags.isEmpty()) { var tags = if (selectedTags.isEmpty()) {
@ -65,19 +58,6 @@ class FilterHeaderProducer @Inject constructor(
if (tags.isEmpty() && selectedTags.isEmpty()) { if (tags.isEmpty() && selectedTags.isEmpty()) {
return emptyList() return emptyList()
} }
for (saved in savedFilters.availableItems) {
val model = ChipsView.ChipModel(
title = saved.name,
isChecked = saved in savedFilters.selectedItems,
data = saved,
)
if (model.isChecked) {
selectedTags.removeAll(saved.filter.tags)
result.addFirst(model)
} else {
result.addLast(model)
}
}
for (tag in tags) { for (tag in tags) {
val model = ChipsView.ChipModel( val model = ChipsView.ChipModel(
title = tag.title, title = tag.title,

@ -1,39 +1,24 @@
package org.koitharu.kotatsu.filter.ui.sheet package org.koitharu.kotatsu.filter.ui.sheet
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.text.InputFilter
import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.LinearLayout
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.slider.RangeSlider import com.google.android.material.slider.RangeSlider
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.titleResId import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.ui.dialog.setEditText
import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.consume
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getDisplayName import org.koitharu.kotatsu.core.util.ext.getDisplayName
@ -42,8 +27,6 @@ import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.ext.setValuesRounded import org.koitharu.kotatsu.core.util.ext.setValuesRounded
import org.koitharu.kotatsu.databinding.SheetFilterBinding import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.filter.data.PersistableFilter
import org.koitharu.kotatsu.filter.data.PersistableFilter.Companion.MAX_TITLE_LENGTH
import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
@ -53,17 +36,12 @@ import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toIntUp import org.koitharu.kotatsu.parsers.util.toIntUp
import java.util.Locale import java.util.Locale
import java.util.TreeSet
class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(), class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
AdapterView.OnItemSelectedListener, AdapterView.OnItemSelectedListener,
View.OnClickListener, ChipsView.OnChipClickListener {
ChipsView.OnChipClickListener,
ChipsView.OnChipLongClickListener,
ChipsView.OnChipCloseClickListener {
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding { override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
return SheetFilterBinding.inflate(inflater, container, false) return SheetFilterBinding.inflate(inflater, container, false)
@ -72,7 +50,10 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
if (dialog == null) { if (dialog == null) {
binding.adjustForEmbeddedLayout() binding.layoutBody.updatePadding(top = binding.layoutBody.paddingBottom)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding.scrollView.scrollIndicators = 0
}
} }
val filter = FilterCoordinator.require(this) val filter = FilterCoordinator.require(this)
filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged) filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
@ -80,14 +61,12 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged) filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged)
filter.tags.observe(viewLifecycleOwner, this::onTagsChanged) filter.tags.observe(viewLifecycleOwner, this::onTagsChanged)
filter.tagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged) filter.tagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged)
filter.authors.observe(viewLifecycleOwner, this::onAuthorsChanged)
filter.states.observe(viewLifecycleOwner, this::onStateChanged) filter.states.observe(viewLifecycleOwner, this::onStateChanged)
filter.contentTypes.observe(viewLifecycleOwner, this::onContentTypesChanged) filter.contentTypes.observe(viewLifecycleOwner, this::onContentTypesChanged)
filter.contentRating.observe(viewLifecycleOwner, this::onContentRatingChanged) filter.contentRating.observe(viewLifecycleOwner, this::onContentRatingChanged)
filter.demographics.observe(viewLifecycleOwner, this::onDemographicsChanged) filter.demographics.observe(viewLifecycleOwner, this::onDemographicsChanged)
filter.year.observe(viewLifecycleOwner, this::onYearChanged) filter.year.observe(viewLifecycleOwner, this::onYearChanged)
filter.yearRange.observe(viewLifecycleOwner, this::onYearRangeChanged) filter.yearRange.observe(viewLifecycleOwner, this::onYearRangeChanged)
filter.savedFilters.observe(viewLifecycleOwner, ::onSavedPresetsChanged)
binding.layoutGenres.setTitle( binding.layoutGenres.setTitle(
if (filter.capabilities.isMultipleTagsSupported) { if (filter.capabilities.isMultipleTagsSupported) {
@ -99,16 +78,12 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
binding.spinnerLocale.onItemSelectedListener = this binding.spinnerLocale.onItemSelectedListener = this
binding.spinnerOriginalLocale.onItemSelectedListener = this binding.spinnerOriginalLocale.onItemSelectedListener = this
binding.spinnerOrder.onItemSelectedListener = this binding.spinnerOrder.onItemSelectedListener = this
binding.chipsSavedFilters.onChipClickListener = this
binding.chipsState.onChipClickListener = this binding.chipsState.onChipClickListener = this
binding.chipsTypes.onChipClickListener = this binding.chipsTypes.onChipClickListener = this
binding.chipsContentRating.onChipClickListener = this binding.chipsContentRating.onChipClickListener = this
binding.chipsDemographics.onChipClickListener = this binding.chipsDemographics.onChipClickListener = this
binding.chipsGenres.onChipClickListener = this binding.chipsGenres.onChipClickListener = this
binding.chipsGenresExclude.onChipClickListener = this binding.chipsGenresExclude.onChipClickListener = this
binding.chipsAuthor.onChipClickListener = this
binding.chipsSavedFilters.onChipLongClickListener = this
binding.chipsSavedFilters.onChipCloseClickListener = this
binding.sliderYear.addOnChangeListener(this::onSliderValueChange) binding.sliderYear.addOnChangeListener(this::onSliderValueChange)
binding.sliderYearsRange.addOnChangeListener(this::onRangeSliderValueChange) binding.sliderYearsRange.addOnChangeListener(this::onRangeSliderValueChange)
binding.layoutGenres.setOnMoreButtonClickListener { binding.layoutGenres.setOnMoreButtonClickListener {
@ -117,45 +92,16 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
binding.layoutGenresExclude.setOnMoreButtonClickListener { binding.layoutGenresExclude.setOnMoreButtonClickListener {
router.showTagsCatalogSheet(excludeMode = true) router.showTagsCatalogSheet(excludeMode = true)
} }
combine(
filter.observe().map { it.listFilter.isNotEmpty() }.distinctUntilChanged(),
filter.savedFilters.map { it.selectedItems.isEmpty() }.distinctUntilChanged(),
Boolean::and,
).flowOn(Dispatchers.Default)
.observe(viewLifecycleOwner) {
binding.buttonSave.isEnabled = it
}
binding.buttonSave.setOnClickListener(this)
binding.buttonDone.setOnClickListener(this)
}
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 { override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val typeMask = WindowInsetsCompat.Type.systemBars() val typeMask = WindowInsetsCompat.Type.systemBars()
viewBinding?.layoutBottom?.updateLayoutParams<ViewGroup.MarginLayoutParams> { viewBinding?.scrollView?.updatePadding(
bottomMargin = insets.getInsets(typeMask).bottom bottom = insets.getInsets(typeMask).bottom,
} )
return insets.consume(v, typeMask, bottom = true) return insets.consume(v, typeMask, bottom = true)
} }
override fun onClick(v: View) {
when (v.id) {
R.id.button_done -> dismiss()
R.id.button_save -> onSaveFilterClick("")
}
}
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
val filter = FilterCoordinator.require(this) val filter = FilterCoordinator.require(this)
when (parent.id) { when (parent.id) {
@ -214,35 +160,10 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
is ContentType -> filter.toggleContentType(data, !chip.isChecked) is ContentType -> filter.toggleContentType(data, !chip.isChecked)
is ContentRating -> filter.toggleContentRating(data, !chip.isChecked) is ContentRating -> filter.toggleContentRating(data, !chip.isChecked)
is Demographic -> filter.toggleDemographic(data, !chip.isChecked) is Demographic -> filter.toggleDemographic(data, !chip.isChecked)
is PersistableFilter -> filter.setAdjusted(data.filter)
is String -> if (chip.isChecked) {
filter.setAuthor(null)
} else {
filter.setAuthor(data)
}
null -> router.showTagsCatalogSheet(excludeMode = chip.parentView?.id == R.id.chips_genresExclude) null -> router.showTagsCatalogSheet(excludeMode = chip.parentView?.id == R.id.chips_genresExclude)
} }
} }
override fun onChipLongClick(chip: Chip, data: Any?): Boolean {
return when (data) {
is PersistableFilter -> {
showSavedFilterMenu(chip, data)
true
}
else -> false
}
}
override fun onChipCloseClick(chip: Chip, data: Any?) {
when (data) {
is PersistableFilter -> {
showSavedFilterMenu(chip, data)
}
}
}
private fun onSortOrderChanged(value: FilterProperty<SortOrder>) { private fun onSortOrderChanged(value: FilterProperty<SortOrder>) {
val b = viewBinding ?: return val b = viewBinding ?: return
b.layoutOrder.isGone = value.isEmpty() b.layoutOrder.isGone = value.isEmpty()
@ -333,22 +254,6 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
b.chipsGenresExclude.setChips(chips) b.chipsGenresExclude.setChips(chips)
} }
private fun onAuthorsChanged(value: FilterProperty<String>) {
val b = viewBinding ?: return
b.layoutAuthor.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { author ->
ChipsView.ChipModel(
title = author,
isChecked = author in value.selectedItems,
data = author,
)
}
b.chipsAuthor.setChips(chips)
}
private fun onStateChanged(value: FilterProperty<MangaState>) { private fun onStateChanged(value: FilterProperty<MangaState>) {
val b = viewBinding ?: return val b = viewBinding ?: return
b.layoutState.isGone = value.isEmpty() b.layoutState.isGone = value.isEmpty()
@ -451,101 +356,4 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
) )
b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo) b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo)
} }
private fun onSavedPresetsChanged(value: FilterProperty<PersistableFilter>) {
val b = viewBinding ?: return
b.layoutSavedFilters.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { f ->
ChipsView.ChipModel(
title = f.name,
isChecked = f in value.selectedItems,
data = f,
isDropdown = true,
)
}
b.chipsSavedFilters.setChips(chips)
}
private fun showSavedFilterMenu(anchor: View, preset: PersistableFilter) {
val menu = PopupMenu(context ?: return, anchor)
val filter = FilterCoordinator.require(this)
menu.inflate(R.menu.popup_saved_filter)
menu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_delete -> filter.deleteSavedFilter(preset.id)
R.id.action_rename -> onRenameFilterClick(preset)
}
true
}
menu.show()
}
private fun onSaveFilterClick(name: String) {
val filter = FilterCoordinator.require(this)
val existingNames = filter.savedFilters.value.availableItems
.mapTo(TreeSet(AlphanumComparator()), PersistableFilter::name)
buildAlertDialog(context ?: return) {
val input = setEditText(
entries = existingNames.toList(),
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES,
singleLine = true,
)
input.setHint(R.string.enter_name)
input.setText(name)
input.filters += InputFilter.LengthFilter(MAX_TITLE_LENGTH)
setTitle(R.string.save_filter)
setPositiveButton(R.string.save) { _, _ ->
val text = input.text?.toString()?.trim()
if (text.isNullOrEmpty()) {
Toast.makeText(context, R.string.invalid_value_message, Toast.LENGTH_SHORT).show()
onSaveFilterClick("")
} else if (text in existingNames) {
askForFilterOverwrite(filter, text)
} else {
filter.saveCurrentFilter(text)
}
}
setNegativeButton(android.R.string.cancel, null)
}.show()
}
private fun onRenameFilterClick(preset: PersistableFilter) {
val filter = FilterCoordinator.require(this)
val existingNames = filter.savedFilters.value.availableItems.mapToSet { it.name }
buildAlertDialog(context ?: return) {
val input = setEditText(
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES,
singleLine = true,
)
input.filters += InputFilter.LengthFilter(MAX_TITLE_LENGTH)
input.setHint(R.string.enter_name)
input.setText(preset.name)
setTitle(R.string.rename)
setPositiveButton(R.string.save) { _, _ ->
val text = input.text?.toString()?.trim()
if (text.isNullOrEmpty() || text in existingNames) {
Toast.makeText(context, R.string.invalid_value_message, Toast.LENGTH_SHORT).show()
} else {
filter.renameSavedFilter(preset.id, text)
}
}
setNegativeButton(android.R.string.cancel, null)
}.show()
}
private fun askForFilterOverwrite(filter: FilterCoordinator, name: String) {
buildAlertDialog(context ?: return) {
setTitle(R.string.save_filter)
setMessage(getString(R.string.filter_overwrite_confirm, name))
setPositiveButton(R.string.overwrite) { _, _ ->
filter.saveCurrentFilter(name)
}
setNegativeButton(android.R.string.cancel) { _, _ ->
onSaveFilterClick(name)
}
}.show()
}
} }

@ -43,9 +43,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
import java.time.Instant import java.time.Instant
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga
import kotlinx.coroutines.flow.SharedFlow
private const val PAGE_SIZE = 16 private const val PAGE_SIZE = 16
@ -57,8 +54,7 @@ class HistoryListViewModel @Inject constructor(
private val markAsReadUseCase: MarkAsReadUseCase, private val markAsReadUseCase: MarkAsReadUseCase,
private val quickFilter: HistoryListQuickFilter, private val quickFilter: HistoryListQuickFilter,
mangaDataRepository: MangaDataRepository, mangaDataRepository: MangaDataRepository,
@LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>, ) : MangaListViewModel(settings, mangaDataRepository), QuickFilterListener by quickFilter {
) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges), QuickFilterListener by quickFilter {
private val sortOrder: StateFlow<ListSortOrder> = settings.observeAsStateFlow( private val sortOrder: StateFlow<ListSortOrder> = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.IO, scope = viewModelScope + Dispatchers.IO,

@ -2,12 +2,14 @@ package org.koitharu.kotatsu.image.ui
import android.content.Context import android.content.Context
import android.graphics.drawable.LayerDrawable import android.graphics.drawable.LayerDrawable
import android.os.Build
import android.util.AttributeSet import android.util.AttributeSet
import android.view.Gravity import android.view.Gravity
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
import android.view.ViewTreeObserver.OnPreDrawListener import android.view.ViewTreeObserver.OnPreDrawListener
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes import androidx.core.content.withStyledAttributes
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
@ -81,8 +83,10 @@ class CoverImageView @JvmOverloads constructor(
if (fallbackDrawable == null) { if (fallbackDrawable == null) {
fallbackDrawable = context.getThemeColor(materialR.attr.colorSurfaceContainer).toDrawable() fallbackDrawable = context.getThemeColor(materialR.attr.colorSurfaceContainer).toDrawable()
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
addImageRequestListener(ErrorForegroundListener()) addImageRequestListener(ErrorForegroundListener())
} }
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec) super.onMeasure(widthMeasureSpec, heightMeasureSpec)
@ -165,6 +169,7 @@ class CoverImageView @JvmOverloads constructor(
} }
} }
@RequiresApi(Build.VERSION_CODES.M)
private inner class ErrorForegroundListener : ImageRequest.Listener { private inner class ErrorForegroundListener : ImageRequest.Listener {
override fun onSuccess(request: ImageRequest, result: SuccessResult) { override fun onSuccess(request: ImageRequest, result: SuccessResult) {
@ -203,7 +208,6 @@ class CoverImageView @JvmOverloads constructor(
is HttpStatusException -> statusCode.toString() is HttpStatusException -> statusCode.toString()
is ContentUnavailableException, is ContentUnavailableException,
is FileNotFoundException -> "404" is FileNotFoundException -> "404"
is TooManyRequestExceptions -> "429" is TooManyRequestExceptions -> "429"
is ParseException -> "</>" is ParseException -> "</>"
is UnsupportedSourceException -> "X" is UnsupportedSourceException -> "X"
@ -265,7 +269,7 @@ class CoverImageView @JvmOverloads constructor(
width = Dimension(height.px * view.aspectRationWidth / view.aspectRationHeight) width = Dimension(height.px * view.aspectRationWidth / view.aspectRationHeight)
} }
} }
return Size(width, height) return Size(checkNotNull(width), checkNotNull(height))
} }
private fun getWidth() = getDimension( private fun getWidth() = getDimension(

@ -3,12 +3,10 @@ package org.koitharu.kotatsu.list.ui
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
@ -24,13 +22,10 @@ import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga
abstract class MangaListViewModel( abstract class MangaListViewModel(
private val settings: AppSettings, private val settings: AppSettings,
private val mangaDataRepository: MangaDataRepository, private val mangaDataRepository: MangaDataRepository,
@param:LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
) : BaseViewModel() { ) : BaseViewModel() {
abstract val content: StateFlow<List<ListModel>> abstract val content: StateFlow<List<ListModel>>
@ -68,11 +63,7 @@ abstract class MangaListViewModel(
protected fun observeListModeWithTriggers(): Flow<ListMode> = combine( protected fun observeListModeWithTriggers(): Flow<ListMode> = combine(
listMode, listMode,
merge(
mangaDataRepository.observeOverridesTrigger(emitInitialState = true), mangaDataRepository.observeOverridesTrigger(emitInitialState = true),
mangaDataRepository.observeFavoritesTrigger(emitInitialState = true),
localStorageChanges.onStart { emit(null) },
),
settings.observeChanges().filter { key -> settings.observeChanges().filter { key ->
key == AppSettings.KEY_PROGRESS_INDICATORS key == AppSettings.KEY_PROGRESS_INDICATORS
|| key == AppSettings.KEY_TRACKER_ENABLED || key == AppSettings.KEY_TRACKER_ENABLED

@ -13,11 +13,11 @@ import androidx.annotation.WorkerThread
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toFile import androidx.core.net.toFile
import dagger.Reusable import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.Cache import okhttp3.Cache
import org.koitharu.kotatsu.core.LocalizedAppContext
import org.koitharu.kotatsu.core.exceptions.NonFileUriException import org.koitharu.kotatsu.core.exceptions.NonFileUriException
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.computeSize
@ -39,7 +39,7 @@ private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
@Reusable @Reusable
class LocalStorageManager @Inject constructor( class LocalStorageManager @Inject constructor(
@LocalizedAppContext private val context: Context, @ApplicationContext private val context: Context,
private val settings: AppSettings, private val settings: AppSettings,
) { ) {

@ -27,6 +27,7 @@ import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
import org.koitharu.kotatsu.parsers.util.json.toStringSet import org.koitharu.kotatsu.parsers.util.json.toStringSet
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.io.File import java.io.File

@ -61,9 +61,7 @@ class LocalMangaParser(private val uri: Uri) {
val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX) val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX)
val mangaInfo = index?.getMangaInfo() val mangaInfo = index?.getMangaInfo()
if (mangaInfo != null) { if (mangaInfo != null) {
val coverEntry: Path? = index.getCoverEntry()?.let { rootPath / it }?.takeIf { val coverEntry: Path? = index.getCoverEntry()?.let { rootPath / it }
fileSystem.exists(it)
}
mangaInfo.copy( mangaInfo.copy(
source = LocalMangaSource, source = LocalMangaSource,
url = rootFile.toUri().toString(), url = rootFile.toUri().toString(),

@ -45,7 +45,7 @@ class LocalListViewModel @Inject constructor(
mangaListMapper: MangaListMapper, mangaListMapper: MangaListMapper,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
exploreRepository: ExploreRepository, exploreRepository: ExploreRepository,
@param:LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>, @LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
private val localStorageManager: LocalStorageManager, private val localStorageManager: LocalStorageManager,
sourcesRepository: MangaSourcesRepository, sourcesRepository: MangaSourcesRepository,
mangaDataRepository: MangaDataRepository, mangaDataRepository: MangaDataRepository,
@ -58,7 +58,6 @@ class LocalListViewModel @Inject constructor(
exploreRepository = exploreRepository, exploreRepository = exploreRepository,
sourcesRepository = sourcesRepository, sourcesRepository = sourcesRepository,
mangaDataRepository = mangaDataRepository, mangaDataRepository = mangaDataRepository,
localStorageChanges = localStorageChanges,
), SharedPreferences.OnSharedPreferenceChangeListener, QuickFilterListener { ), SharedPreferences.OnSharedPreferenceChangeListener, QuickFilterListener {
val onMangaRemoved = MutableEventFlow<Unit>() val onMangaRemoved = MutableEventFlow<Unit>()

@ -1,8 +1,6 @@
package org.koitharu.kotatsu.main.ui package org.koitharu.kotatsu.main.ui
import android.Manifest import android.Manifest
import android.app.BackgroundServiceStartNotAllowedException
import android.app.ServiceStartNotAllowedException
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager.PERMISSION_GRANTED import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Build import android.os.Build
@ -60,7 +58,6 @@ import org.koitharu.kotatsu.core.util.ext.consume
import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.end
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.start import org.koitharu.kotatsu.core.util.ext.start
import org.koitharu.kotatsu.databinding.ActivityMainBinding import org.koitharu.kotatsu.databinding.ActivityMainBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.service.MangaPrefetchService
@ -134,7 +131,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
onBackPressedDispatcher.addCallback(exitCallback) onBackPressedDispatcher.addCallback(exitCallback)
onBackPressedDispatcher.addCallback(navigationDelegate) onBackPressedDispatcher.addCallback(navigationDelegate)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || !resources.getBoolean(R.bool.is_predictive_back_enabled)) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
val legacySearchCallback = SearchViewLegacyBackCallback(viewBinding.searchView) val legacySearchCallback = SearchViewLegacyBackCallback(viewBinding.searchView)
viewBinding.searchView.addTransitionListener(legacySearchCallback) viewBinding.searchView.addTransitionListener(legacySearchCallback)
onBackPressedDispatcher.addCallback(legacySearchCallback) onBackPressedDispatcher.addCallback(legacySearchCallback)
@ -291,7 +288,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
adjustFabVisibility(isResumeEnabled = isEnabled) adjustFabVisibility(isResumeEnabled = isEnabled)
} }
private fun onFirstStart() = try { private fun onFirstStart() {
lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
LocalStorageCleanupWorker.enqueue(applicationContext) LocalStorageCleanupWorker.enqueue(applicationContext)
@ -306,8 +303,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
} }
} }
} }
} catch (e: IllegalStateException) {
e.printStackTraceDebug()
} }
private fun adjustAppbar(topFragment: Fragment) { private fun adjustAppbar(topFragment: Fragment) {

@ -51,7 +51,6 @@ class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipC
binding.chipsType.onChipClickListener = this binding.chipsType.onChipClickListener = this
binding.chipBackup.setOnClickListener(this) binding.chipBackup.setOnClickListener(this)
binding.chipSync.setOnClickListener(this) binding.chipSync.setOnClickListener(this)
binding.chipDirectories.setOnClickListener(this)
viewModel.locales.observe(viewLifecycleOwner, ::onLocalesChanged) viewModel.locales.observe(viewLifecycleOwner, ::onLocalesChanged)
viewModel.types.observe(viewLifecycleOwner, ::onTypesChanged) viewModel.types.observe(viewLifecycleOwner, ::onTypesChanged)
@ -87,10 +86,6 @@ class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipC
val accountType = getString(R.string.account_type_sync) val accountType = getString(R.string.account_type_sync)
am.addAccount(accountType, accountType, null, null, requireActivity(), null, null) am.addAccount(accountType, accountType, null, null, requireActivity(), null, null)
} }
R.id.chip_directories -> {
router.openDirectoriesSettings()
}
} }
} }

@ -20,9 +20,6 @@ import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.SharedFlow
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga
@HiltViewModel @HiltViewModel
class MangaPickerViewModel @Inject constructor( class MangaPickerViewModel @Inject constructor(
@ -31,8 +28,7 @@ class MangaPickerViewModel @Inject constructor(
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository, private val favouritesRepository: FavouritesRepository,
private val mangaListMapper: MangaListMapper, private val mangaListMapper: MangaListMapper,
@LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>, ) : MangaListViewModel(settings, mangaDataRepository) {
) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges) {
override val content: StateFlow<List<ListModel>> override val content: StateFlow<List<ListModel>>
get() = flow { get() = flow {

@ -1,7 +1,6 @@
package org.koitharu.kotatsu.reader.domain package org.koitharu.kotatsu.reader.domain
import android.util.LongSparseArray import android.util.LongSparseArray
import androidx.annotation.CheckResult
import dagger.hilt.android.scopes.ViewModelScoped import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@ -33,12 +32,12 @@ class ChaptersLoader @Inject constructor(
} }
} }
suspend fun loadPrevNextChapter(manga: MangaDetails, currentId: Long, isNext: Boolean): Boolean { suspend fun loadPrevNextChapter(manga: MangaDetails, currentId: Long, isNext: Boolean) {
val chapters = manga.allChapters val chapters = manga.allChapters
val predicate: (MangaChapter) -> Boolean = { it.id == currentId } val predicate: (MangaChapter) -> Boolean = { it.id == currentId }
val index = if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate) val index = if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate)
if (index == -1) return false if (index == -1) return
val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return false val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return
val newPages = loadChapter(newChapter.id) val newPages = loadChapter(newChapter.id)
mutex.withLock { mutex.withLock {
if (chapterPages.chaptersSize > 1) { if (chapterPages.chaptersSize > 1) {
@ -57,16 +56,13 @@ class ChaptersLoader @Inject constructor(
chapterPages.addFirst(newChapter.id, newPages) chapterPages.addFirst(newChapter.id, newPages)
} }
} }
return true
} }
@CheckResult suspend fun loadSingleChapter(chapterId: Long) {
suspend fun loadSingleChapter(chapterId: Long): Boolean {
val pages = loadChapter(chapterId) val pages = loadChapter(chapterId)
return mutex.withLock { mutex.withLock {
chapterPages.clear() chapterPages.clear()
chapterPages.addLast(chapterId, pages) chapterPages.addLast(chapterId, pages)
pages.isNotEmpty()
} }
} }

@ -8,9 +8,11 @@ import android.graphics.Rect
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.core.graphics.alpha import androidx.core.graphics.alpha
import androidx.core.graphics.blue import androidx.core.graphics.blue
import androidx.core.graphics.get
import androidx.core.graphics.green import androidx.core.graphics.green
import androidx.core.graphics.red import androidx.core.graphics.red
import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.decoder.ImageRegionDecoder
import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -21,6 +23,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.util.SynchronizedSieveCache import org.koitharu.kotatsu.core.util.SynchronizedSieveCache
import org.koitharu.kotatsu.core.util.ext.use
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@ -46,7 +49,7 @@ class EdgeDetector(private val context: Context) {
val fullBitmap = decoder.decodeRegion( val fullBitmap = decoder.decodeRegion(
Rect(0, 0, size.x, size.y), Rect(0, 0, size.x, size.y),
sampleSize, sampleSize
) )
try { try {

@ -3,7 +3,7 @@ package org.koitharu.kotatsu.reader.ui
import android.app.assist.AssistContent import android.app.assist.AssistContent
import android.content.DialogInterface import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.Gravity import android.view.Gravity
import android.view.KeyEvent import android.view.KeyEvent
@ -25,8 +25,6 @@ import androidx.transition.Fade
import androidx.transition.Slide import androidx.transition.Slide
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
import androidx.transition.TransitionSet import androidx.transition.TransitionSet
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -34,9 +32,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@ -115,9 +111,6 @@ class ReaderActivity :
private lateinit var readerManager: ReaderManager private lateinit var readerManager: ReaderManager
private val hideUiRunnable = Runnable { setUiIsVisible(false) } private val hideUiRunnable = Runnable { setUiIsVisible(false) }
// Tracks whether the foldable device is in an unfolded state (half-opened or flat)
private var isFoldUnfolded: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityReaderBinding.inflate(layoutInflater)) setContentView(ActivityReaderBinding.inflate(layoutInflater))
@ -195,11 +188,6 @@ class ReaderActivity :
viewBinding.zoomControl.isVisible = it viewBinding.zoomControl.isVisible = it
} }
addMenuProvider(ReaderMenuProvider(viewModel)) addMenuProvider(ReaderMenuProvider(viewModel))
observeWindowLayout()
// Apply initial double-mode considering foldable setting
applyDoubleModeAuto()
} }
override fun getParentActivityIntent(): Intent? { override fun getParentActivityIntent(): Intent? {
@ -227,8 +215,10 @@ class ReaderActivity :
override fun onProvideAssistContent(outContent: AssistContent) { override fun onProvideAssistContent(outContent: AssistContent) {
super.onProvideAssistContent(outContent) super.onProvideAssistContent(outContent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it } viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it }
} }
}
override fun isNsfwContent(): Flow<Boolean> = viewModel.isMangaNsfw override fun isNsfwContent(): Flow<Boolean> = viewModel.isMangaNsfw
@ -354,17 +344,7 @@ class ReaderActivity :
} }
override fun onDoubleModeChanged(isEnabled: Boolean) { override fun onDoubleModeChanged(isEnabled: Boolean) {
// Combine manual toggle with foldable auto setting readerManager.setDoubleReaderMode(isEnabled)
applyDoubleModeAuto(isEnabled)
}
private fun applyDoubleModeAuto(manualEnabled: Boolean? = null) {
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
// Auto double-page on foldable when device is unfolded (half-opened or flat)
val autoFoldable = settings.isReaderDoubleOnFoldable && isFoldUnfolded
val manualLandscape = (manualEnabled ?: settings.isReaderDoubleOnLandscape) && isLandscape
val autoEnabled = autoFoldable || manualLandscape
readerManager.setDoubleReaderMode(autoEnabled)
} }
private fun setKeepScreenOn(isKeep: Boolean) { private fun setKeepScreenOn(isKeep: Boolean) {
@ -394,7 +374,6 @@ class ReaderActivity :
viewBinding.infoBar.isTimeVisible = isFullscreen viewBinding.infoBar.isTimeVisible = isFullscreen
updateScrollTimerButton() updateScrollTimerButton()
systemUiController.setSystemUiVisible(isUiVisible || !isFullscreen) systemUiController.setSystemUiVisible(isUiVisible || !isFullscreen)
viewBinding.root.requestApplyInsets()
} }
} }
@ -416,14 +395,8 @@ class ReaderActivity :
viewBinding.infoBar.updatePadding( viewBinding.infoBar.updatePadding(
top = systemBars.top, top = systemBars.top,
) )
val innerInsets = Insets.of(
systemBars.left,
if (viewBinding.appbarTop.isVisible) viewBinding.appbarTop.height else systemBars.top,
systemBars.right,
viewBinding.toolbarDocked?.takeIf { it.isVisible }?.height ?: systemBars.bottom,
)
return WindowInsetsCompat.Builder(insets) return WindowInsetsCompat.Builder(insets)
.setInsets(WindowInsetsCompat.Type.systemBars(), innerInsets) .setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE)
.build() .build()
} }
@ -511,11 +484,7 @@ class ReaderActivity :
uiState.incognito -> getString(R.string.incognito_mode) uiState.incognito -> getString(R.string.incognito_mode)
else -> chapterTitle else -> chapterTitle
} }
if ( if (chapterTitle != previous?.getChapterTitle(resources) && chapterTitle.isNotEmpty()) {
settings.isReaderChapterToastEnabled &&
chapterTitle != previous?.getChapterTitle(resources) &&
chapterTitle.isNotEmpty()
) {
viewBinding.toastView.showTemporary(chapterTitle, TOAST_DURATION) viewBinding.toastView.showTemporary(chapterTitle, TOAST_DURATION)
} }
if (uiState.isSliderAvailable()) { if (uiState.isSliderAvailable()) {
@ -544,24 +513,6 @@ class ReaderActivity :
} }
} }
// Observe foldable window layout to auto-enable double-page if configured
private fun observeWindowLayout() {
WindowInfoTracker.getOrCreate(this)
.windowLayoutInfo(this)
.onEach { info ->
val fold = info.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()
val unfolded = when (fold?.state) {
FoldingFeature.State.HALF_OPENED, FoldingFeature.State.FLAT -> true
else -> false
}
if (unfolded != isFoldUnfolded) {
isFoldUnfolded = unfolded
applyDoubleModeAuto()
}
}
.launchIn(lifecycleScope)
}
private fun askForIncognitoMode() { private fun askForIncognitoMode() {
buildAlertDialog(this, isCentered = true) { buildAlertDialog(this, isCentered = true) {
var dontAskAgain = false var dontAskAgain = false

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

@ -29,7 +29,6 @@ import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
import org.koitharu.kotatsu.core.model.getPreferredBranch import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.nav.MangaIntent import org.koitharu.kotatsu.core.nav.MangaIntent
import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.nav.ReaderIntent
@ -48,7 +47,6 @@ import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.domain.DetailsInteractor import org.koitharu.kotatsu.details.domain.DetailsInteractor
import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.domain.HistoryUpdateUseCase import org.koitharu.kotatsu.history.domain.HistoryUpdateUseCase
@ -159,12 +157,6 @@ class ReaderViewModel @Inject constructor(
valueProducer = { isWebtoonGapsEnabled }, valueProducer = { isWebtoonGapsEnabled },
) )
val isWebtoonPullGestureEnabled = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_WEBTOON_PULL_GESTURE,
valueProducer = { isWebtoonPullGestureEnabled },
)
val defaultWebtoonZoomOut = observeIsWebtoonZoomEnabled().flatMapLatest { val defaultWebtoonZoomOut = observeIsWebtoonZoomEnabled().flatMapLatest {
if (it) { if (it) {
observeWebtoonZoomOut() observeWebtoonZoomOut()
@ -353,15 +345,12 @@ class ReaderViewModel @Inject constructor(
return@launchJob return@launchJob
} }
ensureActive() ensureActive()
val autoLoadAllowed = readerMode.value != ReaderMode.WEBTOON || !isWebtoonPullGestureEnabled.value
if (autoLoadAllowed) {
if (upperPos >= pages.lastIndex - BOUNDS_PAGE_OFFSET) { if (upperPos >= pages.lastIndex - BOUNDS_PAGE_OFFSET) {
loadPrevNextChapter(pages.last().chapterId, isNext = true) loadPrevNextChapter(pages.last().chapterId, isNext = true)
} }
if (lowerPos <= BOUNDS_PAGE_OFFSET) { if (lowerPos <= BOUNDS_PAGE_OFFSET) {
loadPrevNextChapter(pages.first().chapterId, isNext = false) loadPrevNextChapter(pages.first().chapterId, isNext = false)
} }
}
if (pageLoader.isPrefetchApplicable()) { if (pageLoader.isPrefetchApplicable()) {
pageLoader.prefetch(pages.trySublist(upperPos + 1, upperPos + PREFETCH_LIMIT)) pageLoader.prefetch(pages.trySublist(upperPos + 1, upperPos + PREFETCH_LIMIT))
} }
@ -407,11 +396,9 @@ class ReaderViewModel @Inject constructor(
private fun loadImpl() { private fun loadImpl() {
loadingJob = launchLoadingJob(Dispatchers.Default + EventExceptionHandler(onLoadingError)) { loadingJob = launchLoadingJob(Dispatchers.Default + EventExceptionHandler(onLoadingError)) {
var exception: Exception? = null var exception: Exception? = null
var loadedDetails: MangaDetails? = null
try { try {
detailsLoadUseCase(intent, force = false) detailsLoadUseCase(intent, force = false)
.collect { details -> .collect { details ->
loadedDetails = details
if (mangaDetails.value == null) { if (mangaDetails.value == null) {
mangaDetails.value = details mangaDetails.value = details
} }
@ -456,28 +443,9 @@ class ReaderViewModel @Inject constructor(
exception = e.mergeWith(exception) exception = e.mergeWith(exception)
} }
if (readingState.value == null) { if (readingState.value == null) {
val loadedManga = loadedDetails // for smart cast onLoadingError.call(
if (loadedManga != null) { exception ?: IllegalStateException("Unable to load manga. This should never happen. Please report"),
mangaDetails.value = loadedManga.filterChapters(selectedBranch.value)
}
val loadingError = when {
exception != null -> exception
loadedManga == null || !loadedManga.isLoaded -> null
loadedManga.isRestricted -> EmptyMangaException(
EmptyMangaReason.RESTRICTED,
loadedManga.toManga(),
null,
) )
loadedManga.allChapters.isEmpty() -> EmptyMangaException(
EmptyMangaReason.NO_CHAPTERS,
loadedManga.toManga(),
null,
)
else -> null
} ?: IllegalStateException("Unable to load manga. This should never happen. Please report")
onLoadingError.call(loadingError)
} else exception?.let { e -> } else exception?.let { e ->
// manga has been loaded but error occurred // manga has been loaded but error occurred
errorEvent.call(e) errorEvent.call(e)

@ -10,9 +10,7 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.transition.TransitionManager
import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.button.MaterialButtonToggleGroup
import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -27,9 +25,7 @@ import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.consume
import org.koitharu.kotatsu.core.util.ext.findParentCallback import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter
import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.ReaderViewModel import org.koitharu.kotatsu.reader.ui.ReaderViewModel
@ -41,8 +37,7 @@ class ReaderConfigSheet :
BaseAdaptiveSheet<SheetReaderConfigBinding>(), BaseAdaptiveSheet<SheetReaderConfigBinding>(),
View.OnClickListener, View.OnClickListener,
MaterialButtonToggleGroup.OnButtonCheckedListener, MaterialButtonToggleGroup.OnButtonCheckedListener,
CompoundButton.OnCheckedChangeListener, CompoundButton.OnCheckedChangeListener {
Slider.OnChangeListener {
private val viewModel by activityViewModels<ReaderViewModel>() private val viewModel by activityViewModels<ReaderViewModel>()
@ -91,11 +86,6 @@ class ReaderConfigSheet :
binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL
binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape
binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED
binding.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.checkableGroup.addOnButtonCheckedListener(this)
binding.buttonSavePage.setOnClickListener(this) binding.buttonSavePage.setOnClickListener(this)
@ -106,8 +96,6 @@ class ReaderConfigSheet :
binding.buttonScrollTimer.setOnClickListener(this) binding.buttonScrollTimer.setOnClickListener(this)
binding.buttonBookmark.setOnClickListener(this) binding.buttonBookmark.setOnClickListener(this)
binding.switchDoubleReader.setOnCheckedChangeListener(this) binding.switchDoubleReader.setOnCheckedChangeListener(this)
binding.switchDoubleFoldable.setOnCheckedChangeListener(this)
binding.sliderDoubleSensitivity.addOnChangeListener(this)
viewModel.isBookmarkAdded.observe(viewLifecycleOwner) { viewModel.isBookmarkAdded.observe(viewLifecycleOwner) {
binding.buttonBookmark.setText(if (it) R.string.bookmark_remove else R.string.bookmark_add) binding.buttonBookmark.setText(if (it) R.string.bookmark_remove else R.string.bookmark_add)
@ -182,22 +170,11 @@ class ReaderConfigSheet :
R.id.switch_double_reader -> { R.id.switch_double_reader -> {
settings.isReaderDoubleOnLandscape = isChecked settings.isReaderDoubleOnLandscape = isChecked
viewBinding?.adjustSensitivitySlider(withAnimation = true)
findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked) findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked)
} }
R.id.switch_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( override fun onButtonChecked(
group: MaterialButtonToggleGroup?, group: MaterialButtonToggleGroup?,
checkedId: Int, checkedId: Int,
@ -213,11 +190,7 @@ class ReaderConfigSheet :
R.id.button_vertical -> ReaderMode.VERTICAL R.id.button_vertical -> ReaderMode.VERTICAL
else -> return else -> return
} }
viewBinding?.run { viewBinding?.switchDoubleReader?.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED
switchDoubleReader.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED
switchDoubleFoldable.isEnabled = switchDoubleReader.isEnabled
adjustSensitivitySlider(withAnimation = true)
}
if (newMode == mode) { if (newMode == mode) {
return return
} }
@ -251,21 +224,6 @@ class ReaderConfigSheet :
) )
} }
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 { interface Callback {
fun onReaderModeChanged(mode: ReaderMode) fun onReaderModeChanged(mode: ReaderMode)

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

@ -11,14 +11,11 @@ import androidx.recyclerview.widget.OrientationHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.SmoothScroller.ScrollVectorProvider import androidx.recyclerview.widget.RecyclerView.SmoothScroller.ScrollVectorProvider
import androidx.recyclerview.widget.SnapHelper import androidx.recyclerview.widget.SnapHelper
import org.koitharu.kotatsu.core.prefs.AppSettings
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.absoluteValue
import kotlin.math.max import kotlin.math.max
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.sign
class DoublePageSnapHelper(private val settings: AppSettings) : SnapHelper() { class DoublePageSnapHelper : SnapHelper() {
private lateinit var recyclerView: RecyclerView private lateinit var recyclerView: RecyclerView
@ -251,27 +248,28 @@ class DoublePageSnapHelper(private val settings: AppSettings) : SnapHelper() {
equal to zero. equal to zero.
*/ */
fun getPositionsToMove(llm: LinearLayoutManager, scroll: Int, itemSize: Int): Int { fun getPositionsToMove(llm: LinearLayoutManager, scroll: Int, itemSize: Int): Int {
val sensitivity = settings.readerDoublePagesSensitivity.coerceIn(0f, 1f) * 2.5 var positionsToMove: Int
var positionsToMove = (scroll.toDouble() / (itemSize * (2.5 - sensitivity))).roundToInt() positionsToMove = roundUpToBlockSize(abs((scroll.toDouble()) / itemSize).roundToInt())
if (positionsToMove < blockSize) {
// Apply a maximum threshold // Must move at least one block
val maxPages = (4 * sensitivity).roundToInt().coerceAtLeast(1) positionsToMove = blockSize
if (positionsToMove.absoluteValue > maxPages) { } else if (positionsToMove > maxPositionsToMove) {
positionsToMove = maxPages * positionsToMove.sign // Clamp number of positions to move, so we don't get wild flinging.
positionsToMove = maxPositionsToMove
}
if (scroll < 0) {
positionsToMove *= -1
} }
if (isRTL) {
// Apply a minimum threshold positionsToMove *= -1
if (positionsToMove == 0 && scroll.absoluteValue > itemSize * 0.2) {
positionsToMove = 1 * scroll.sign
} }
return if (layoutDirectionHelper.isDirectionToBottom(scroll < 0)) {
val currentPosition = if (layoutDirectionHelper.isDirectionToBottom(scroll < 0)) { // Scrolling toward the bottom of data.
llm.findFirstVisibleItemPosition() roundDownToBlockSize(llm.findFirstVisibleItemPosition()) + positionsToMove
} else { } else {
llm.findLastVisibleItemPosition() roundDownToBlockSize(llm.findLastVisibleItemPosition()) + positionsToMove
} }
val targetPos = currentPosition + positionsToMove * 2 // Scrolling toward the top of the data.
return roundDownToBlockSize(targetPos)
} }
fun isDirectionToBottom(velocityNegative: Boolean): Boolean { fun isDirectionToBottom(velocityNegative: Boolean): Boolean {

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

Loading…
Cancel
Save