Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be012f631a | ||
|
|
0165f43603 | ||
|
|
55801a1488 | ||
|
|
77103f016f | ||
|
|
6b6719a259 | ||
|
|
822642abb0 | ||
|
|
260745fb95 | ||
|
|
024ec0388f | ||
|
|
5345998eec | ||
|
|
3d56190e71 | ||
|
|
954431d0a5 | ||
|
|
afec63b443 | ||
|
|
ac5b29c35a | ||
|
|
59f5578b66 | ||
|
|
391dbb4237 | ||
|
|
7d4505eb78 | ||
|
|
e6ceb20cf7 | ||
|
|
8004f8c093 | ||
|
|
61bf2abb6c | ||
|
|
d9612f3427 | ||
|
|
435c3824f7 | ||
|
|
c846693570 | ||
|
|
123937cd01 | ||
|
|
9f56554313 | ||
|
|
f8687bb697 | ||
|
|
43d3a2cc6a | ||
|
|
a95db6ed21 | ||
|
|
fd0bb57338 | ||
|
|
6b94bc2632 | ||
|
|
c8b91599c6 | ||
|
|
3a8b0f9e93 | ||
|
|
17a0725666 | ||
|
|
3be7848ad9 | ||
|
|
08202c11a3 | ||
|
|
5ef907d046 | ||
|
|
c3776ea3c6 | ||
|
|
a624bffea3 | ||
|
|
8f38b4fe30 | ||
|
|
71a2de5358 | ||
|
|
5478f8fb59 | ||
|
|
5155c9a33d | ||
|
|
f7a461a9d8 | ||
|
|
3a02d22e02 | ||
|
|
2b8a29e2a6 | ||
|
|
bc68441585 | ||
|
|
1cc51b6a88 | ||
|
|
fd5aca7252 | ||
|
|
e447245fac | ||
|
|
5af0ee1c69 | ||
|
|
c02d1641ab | ||
|
|
f55c525c8a | ||
|
|
a42fc87a9a | ||
|
|
6b6905fd71 | ||
|
|
b7f57856db | ||
|
|
1d6d626b62 | ||
|
|
d93ff92cc9 | ||
|
|
8eda113f3b | ||
|
|
3916c2619e | ||
|
|
1d3e8e55ca | ||
|
|
2c3b4f29eb | ||
|
|
ee530002b6 | ||
|
|
59d530e0dc | ||
|
|
52a132caed | ||
|
|
379d2dd8d4 | ||
|
|
f8cefa3e8d | ||
|
|
5e1eda850c | ||
|
|
18cc0ad0fb | ||
|
|
11dd49c626 | ||
|
|
2ad8ab0258 | ||
|
|
4f8c5325a4 | ||
|
|
6e181a59a3 | ||
|
|
7a7d20dbf4 | ||
|
|
83d5f8e378 | ||
|
|
5ac9bad728 | ||
|
|
a090965a2d | ||
|
|
1e376754bc | ||
|
|
2cdbe52056 | ||
|
|
1e09ac3ecb | ||
|
|
acc76c931a | ||
|
|
59c12d35c1 | ||
|
|
0e3cad1af1 | ||
|
|
ba8766b32d | ||
|
|
35421cb71e | ||
|
|
8cecd9a0e2 | ||
|
|
523057f3e1 | ||
|
|
337d196bc3 | ||
|
|
c3b4c032bb | ||
|
|
4590c753ed | ||
|
|
9733101f0c | ||
|
|
8cd71cc98d | ||
|
|
42748d9c98 | ||
|
|
8043574314 | ||
|
|
44d1fdb9d3 | ||
|
|
bc7054de4a | ||
|
|
4971e8ab0f | ||
|
|
df038b1edb | ||
|
|
7e7aabc1d1 | ||
|
|
9605ff89fb | ||
|
|
4ed177d29f | ||
|
|
61cefefd10 | ||
|
|
9f965c5269 | ||
|
|
0c713cb799 | ||
|
|
6d3f8cbb3b | ||
|
|
05739bb5b3 | ||
|
|
47f0bbee17 | ||
|
|
dd77926dcb | ||
|
|
1b76f21507 | ||
|
|
fe21af5443 | ||
|
|
0b0373021e | ||
|
|
d641e7933d | ||
|
|
d8efe374a8 | ||
|
|
506a8b6e90 | ||
|
|
d81173bf76 | ||
|
|
896452a096 | ||
|
|
35aa4d5e8f | ||
|
|
4d4c9c7a48 | ||
|
|
b667e32598 | ||
|
|
c987fc234b | ||
|
|
8142a6811b | ||
|
|
3e36e1e11c | ||
|
|
30aaca6341 | ||
|
|
43b34a7bca | ||
|
|
b23008d0ae | ||
|
|
5a368b27bb | ||
|
|
fe3f95d160 | ||
|
|
de1a297338 | ||
|
|
d6350afe3a | ||
|
|
ec048c70f1 | ||
|
|
282c1b51f7 | ||
|
|
d6b6ce1bcd | ||
|
|
f48444dcf6 | ||
|
|
15ba766643 | ||
|
|
a0dbbcb350 | ||
|
|
f72bba9557 | ||
|
|
207791aa3e | ||
|
|
6319997716 | ||
|
|
b70c1da54b | ||
|
|
621cb19c5b | ||
|
|
b528b7b3c1 | ||
|
|
9a1bb6f6fc | ||
|
|
37f9c4b9f6 | ||
|
|
d0084e50e7 | ||
|
|
088576cc9d | ||
|
|
f0ba42b518 |
16
.github/workflows/trigger-site-deploy.yml
vendored
Normal file
16
.github/workflows/trigger-site-deploy.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
name: Trigger Site Update
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
trigger-site:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send repository_dispatch to site-repo
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
with:
|
||||
token: ${{ secrets.SITE_REPO_TOKEN }}
|
||||
repository: KotatsuApp/website
|
||||
event-type: app-release
|
||||
2
.idea/.gitignore
generated
vendored
2
.idea/.gitignore
generated
vendored
@@ -3,3 +3,5 @@
|
||||
/workspace.xml
|
||||
/migrations.xml
|
||||
/runConfigurations.xml
|
||||
/appInsightsSettings.xml
|
||||
/kotlinCodeInsightSettings.xml
|
||||
|
||||
26
.idea/appInsightsSettings.xml
generated
Normal file
26
.idea/appInsightsSettings.xml
generated
Normal file
@@ -0,0 +1,26 @@
|
||||
<?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>
|
||||
2
.idea/vcs.xml
generated
2
.idea/vcs.xml
generated
@@ -10,6 +10,6 @@
|
||||
</option>
|
||||
</component>
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in online content sources.**
|
||||
|
||||
   [](https://github.com/KotatsuApp/kotatsu-parsers) [](https://hosted.weblate.org/engage/kotatsu/) [](https://discord.gg/NNJ5RgVBC5) [](https://t.me/kotatsuapp) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||
   [](https://github.com/KotatsuApp/kotatsu-parsers) [](https://hosted.weblate.org/engage/kotatsu/) [](https://discord.gg/NNJ5RgVBC5) [](https://t.me/kotatsuapp) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||
|
||||
### Download
|
||||
|
||||
|
||||
@@ -8,19 +8,21 @@ plugins {
|
||||
id 'dagger.hilt.android.plugin'
|
||||
id 'androidx.room'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
// enable if needed
|
||||
// id 'dev.reformator.stacktracedecoroutinator'
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
buildToolsVersion = '35.0.0'
|
||||
namespace = 'org.koitharu.kotatsu'
|
||||
|
||||
defaultConfig {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 35
|
||||
versionCode = 1022
|
||||
versionName = '9.0'
|
||||
minSdk = 23
|
||||
targetSdk = 36
|
||||
versionCode = 1030
|
||||
versionName = '9.2'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
@@ -30,6 +32,12 @@ android {
|
||||
// https://issuetracker.google.com/issues/408030127
|
||||
generateLocaleConfig false
|
||||
}
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localProperties.load(new FileInputStream(localPropertiesFile))
|
||||
}
|
||||
resValue 'string', 'tg_backup_bot_token', localProperties.getProperty('tg_backup_bot_token', '')
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
@@ -79,6 +87,7 @@ android {
|
||||
'-opt-in=coil3.annotation.InternalCoilApi',
|
||||
'-opt-in=kotlinx.serialization.ExperimentalSerializationApi',
|
||||
'-Xjspecify-annotations=strict',
|
||||
'-Xannotation-default-target=first-only',
|
||||
'-Xtype-enhancement-improvements-strict-mode'
|
||||
]
|
||||
}
|
||||
@@ -172,6 +181,7 @@ dependencies {
|
||||
implementation libs.ssiv
|
||||
implementation libs.disk.lru.cache
|
||||
implementation libs.markwon
|
||||
implementation libs.kizzyrpc
|
||||
|
||||
implementation libs.acra.http
|
||||
implementation libs.acra.dialog
|
||||
@@ -179,6 +189,7 @@ dependencies {
|
||||
implementation libs.conscrypt.android
|
||||
|
||||
debugImplementation libs.leakcanary.android
|
||||
nightlyImplementation libs.leakcanary.android
|
||||
debugImplementation libs.workinspector
|
||||
|
||||
testImplementation libs.junit
|
||||
|
||||
7
app/proguard-rules.pro
vendored
7
app/proguard-rules.pro
vendored
@@ -8,8 +8,7 @@
|
||||
public static void checkParameterIsNotNull(...);
|
||||
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 org.conscrypt.**
|
||||
-dontwarn org.bouncycastle.**
|
||||
@@ -17,8 +16,10 @@
|
||||
-dontwarn com.google.j2objc.annotations.**
|
||||
-dontwarn coil3.PlatformContext
|
||||
|
||||
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
||||
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
||||
-keep class org.koitharu.kotatsu.settings.about.changelog.ChangelogFragment
|
||||
|
||||
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
||||
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
|
||||
-keep class org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment { *; }
|
||||
-keep class org.jsoup.parser.Tag
|
||||
|
||||
@@ -41,8 +41,8 @@ class KotatsuApp : BaseApp() {
|
||||
detectNetwork()
|
||||
detectDiskWrites()
|
||||
detectCustomSlowCalls()
|
||||
detectResourceMismatches()
|
||||
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()
|
||||
penaltyLog()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||
@@ -56,7 +56,9 @@ class KotatsuApp : BaseApp() {
|
||||
detectLeakedSqlLiteObjects()
|
||||
detectLeakedClosableObjects()
|
||||
detectLeakedRegistrationObjects()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectContentUriWithoutPermission()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
detectContentUriWithoutPermission()
|
||||
}
|
||||
detectFileUriExposure()
|
||||
penaltyLog()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.preference.Preference
|
||||
import leakcanary.LeakCanary
|
||||
import org.koitharu.kotatsu.KotatsuApp
|
||||
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.settings.utils.SplitSwitchPreference
|
||||
import org.koitharu.workinspector.WorkInspector
|
||||
|
||||
class DebugSettingsFragment : BasePreferenceFragment(R.string.debug), Preference.OnPreferenceChangeListener,
|
||||
Preference.OnPreferenceClickListener {
|
||||
|
||||
private val application
|
||||
get() = requireContext().applicationContext as KotatsuApp
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_debug)
|
||||
findPreference<SplitSwitchPreference>(KEY_LEAK_CANARY)?.let { pref ->
|
||||
pref.isChecked = application.isLeakCanaryEnabled
|
||||
pref.onPreferenceChangeListener = this
|
||||
pref.onContainerClickListener = this
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
findPreference<SplitSwitchPreference>(KEY_LEAK_CANARY)?.isChecked = application.isLeakCanaryEnabled
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) {
|
||||
KEY_WORK_INSPECTOR -> {
|
||||
startActivity(WorkInspector.getIntent(preference.context))
|
||||
true
|
||||
}
|
||||
|
||||
KEY_TEST_PARSER -> {
|
||||
router.openList(TestMangaSource, null, null)
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
|
||||
override fun onPreferenceClick(preference: Preference): Boolean = when (preference.key) {
|
||||
KEY_LEAK_CANARY -> {
|
||||
startActivity(LeakCanary.newLeakDisplayActivityIntent())
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
|
||||
override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean = when (preference.key) {
|
||||
KEY_LEAK_CANARY -> {
|
||||
application.isLeakCanaryEnabled = newValue as Boolean
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val KEY_LEAK_CANARY = "leak_canary"
|
||||
const val KEY_WORK_INSPECTOR = "work_inspector"
|
||||
const val KEY_TEST_PARSER = "test_parser"
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.core.view.MenuProvider
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import leakcanary.LeakCanary
|
||||
import org.koitharu.kotatsu.KotatsuApp
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.workinspector.WorkInspector
|
||||
|
||||
class SettingsMenuProvider(
|
||||
private val context: Context,
|
||||
) : MenuProvider {
|
||||
|
||||
private val application: KotatsuApp
|
||||
get() = context.applicationContext as KotatsuApp
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.opt_settings, menu)
|
||||
}
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
super.onPrepareMenu(menu)
|
||||
menu.findItem(R.id.action_leakcanary).isChecked = application.isLeakCanaryEnabled
|
||||
menu.findItem(R.id.action_ssiv_debug).isChecked = SubsamplingScaleImageView.isDebug
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||
R.id.action_leaks -> {
|
||||
context.startActivity(LeakCanary.newLeakDisplayActivityIntent())
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_works -> {
|
||||
context.startActivity(WorkInspector.getIntent(context))
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_leakcanary -> {
|
||||
val checked = !menuItem.isChecked
|
||||
menuItem.isChecked = checked
|
||||
application.isLeakCanaryEnabled = checked
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_ssiv_debug -> {
|
||||
val checked = !menuItem.isChecked
|
||||
menuItem.isChecked = checked
|
||||
SubsamplingScaleImageView.isDebug = checked
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
12
app/src/debug/res/drawable/ic_debug.xml
Normal file
12
app/src/debug/res/drawable/ic_debug.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000"
|
||||
android:pathData="M20,8H17.19C16.74,7.2 16.12,6.5 15.37,6L17,4.41L15.59,3L13.42,5.17C12.96,5.06 12.5,5 12,5C11.5,5 11.05,5.06 10.59,5.17L8.41,3L7,4.41L8.62,6C7.87,6.5 7.26,7.21 6.81,8H4V10H6.09C6.03,10.33 6,10.66 6,11V12H4V14H6V15C6,15.34 6.03,15.67 6.09,16H4V18H6.81C8.47,20.87 12.14,21.84 15,20.18C15.91,19.66 16.67,18.9 17.19,18H20V16H17.91C17.97,15.67 18,15.34 18,15V14H20V12H18V11C18,10.66 17.97,10.33 17.91,10H20V8M16,15A4,4 0 0,1 12,19A4,4 0 0,1 8,15V11A4,4 0 0,1 12,7A4,4 0 0,1 16,11V15M14,10V12H10V10H14M10,14H14V16H10V14Z" />
|
||||
</vector>
|
||||
@@ -1,30 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_ssiv_debug"
|
||||
android:checkable="true"
|
||||
android:title="SSIV debug"
|
||||
app:showAsAction="never"
|
||||
tools:ignore="HardcodedText" />
|
||||
<item
|
||||
android:id="@+id/action_leakcanary"
|
||||
android:checkable="true"
|
||||
android:title="LeakCanary"
|
||||
app:showAsAction="never"
|
||||
tools:ignore="HardcodedText" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_leaks"
|
||||
android:title="@string/leak_canary_display_activity_label"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_works"
|
||||
android:title="@string/wi_lib_name"
|
||||
app:showAsAction="never" />
|
||||
|
||||
</menu>
|
||||
23
app/src/debug/res/xml/pref_debug.xml
Normal file
23
app/src/debug/res/xml/pref_debug.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.preference.PreferenceScreen
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
|
||||
android:key="leak_canary"
|
||||
android:persistent="false"
|
||||
android:title="LeakCanary" />
|
||||
|
||||
<Preference
|
||||
android:key="work_inspector"
|
||||
android:persistent="false"
|
||||
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>
|
||||
11
app/src/debug/res/xml/pref_root_debug.xml
Normal file
11
app/src/debug/res/xml/pref_root_debug.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.preference.PreferenceScreen
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<PreferenceScreen
|
||||
android:fragment="org.koitharu.kotatsu.settings.DebugSettingsFragment"
|
||||
android:icon="@drawable/ic_debug"
|
||||
android:key="debug"
|
||||
android:title="@string/debug" />
|
||||
|
||||
</androidx.preference.PreferenceScreen>
|
||||
@@ -51,6 +51,7 @@
|
||||
android:backupAgent="org.koitharu.kotatsu.backups.domain.AppBackupAgent"
|
||||
android:dataExtractionRules="@xml/backup_rules"
|
||||
android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled"
|
||||
android:extractNativeLibs="true"
|
||||
android:fullBackupContent="@xml/backup_content"
|
||||
android:fullBackupOnly="true"
|
||||
android:hasFragileUserData="true"
|
||||
@@ -287,6 +288,9 @@
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.scrobbling.discord.ui.DiscordAuthActivity"
|
||||
android:label="@string/discord" />
|
||||
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
@@ -404,6 +408,13 @@
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
|
||||
<receiver
|
||||
android:name="org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler$DiscardReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.koitharu.kotatsu.CAPTCHA_DISCARD" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider"
|
||||
android:exported="true"
|
||||
|
||||
@@ -30,21 +30,19 @@ constructor(
|
||||
oldManga: Manga,
|
||||
newManga: Manga,
|
||||
) {
|
||||
val oldDetails =
|
||||
if (oldManga.chapters.isNullOrEmpty()) {
|
||||
runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||
}.getOrDefault(oldManga)
|
||||
} else {
|
||||
oldManga
|
||||
}
|
||||
val newDetails =
|
||||
if (newManga.chapters.isNullOrEmpty()) {
|
||||
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
||||
} else {
|
||||
newManga
|
||||
}
|
||||
mangaDataRepository.storeManga(newDetails)
|
||||
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
|
||||
runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||
}.getOrDefault(oldManga)
|
||||
} else {
|
||||
oldManga
|
||||
}
|
||||
val newDetails = if (newManga.chapters.isNullOrEmpty()) {
|
||||
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
||||
} else {
|
||||
newManga
|
||||
}
|
||||
mangaDataRepository.storeManga(newDetails, replaceExisting = true)
|
||||
database.withTransaction {
|
||||
// replace favorites
|
||||
val favoritesDao = database.getFavouritesDao()
|
||||
@@ -101,11 +99,11 @@ constructor(
|
||||
mangaId = newDetails.id,
|
||||
rating = prevInfo.rating,
|
||||
status =
|
||||
prevInfo.status ?: when {
|
||||
newHistory == null -> ScrobblingStatus.PLANNED
|
||||
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
|
||||
else -> ScrobblingStatus.READING
|
||||
},
|
||||
prevInfo.status ?: when {
|
||||
newHistory == null -> ScrobblingStatus.PLANNED
|
||||
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
|
||||
else -> ScrobblingStatus.READING
|
||||
},
|
||||
comment = prevInfo.comment,
|
||||
)
|
||||
if (newHistory != null) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
|
||||
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase.NoAlternativesException
|
||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
@@ -47,7 +48,7 @@ class AutoFixService : CoroutineIntentService() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||
notificationManager = NotificationManagerCompat.from(this)
|
||||
}
|
||||
|
||||
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||
@@ -58,7 +59,7 @@ class AutoFixService : CoroutineIntentService() {
|
||||
val result = runCatchingCancellable {
|
||||
autoFixUseCase.invoke(mangaId)
|
||||
}
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
if (checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = buildNotification(startId, result)
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
}
|
||||
@@ -67,7 +68,7 @@ class AutoFixService : CoroutineIntentService() {
|
||||
}
|
||||
|
||||
override fun IntentJobContext.onError(error: Throwable) {
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
if (checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = runBlocking { buildNotification(startId, Result.failure(error)) }
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
}
|
||||
@@ -75,7 +76,7 @@ class AutoFixService : CoroutineIntentService() {
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
private fun startForeground(jobContext: IntentJobContext) {
|
||||
val title = applicationContext.getString(R.string.fixing_manga)
|
||||
val title = getString(R.string.fixing_manga)
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
|
||||
.setName(title)
|
||||
.setShowBadge(false)
|
||||
@@ -85,7 +86,7 @@ class AutoFixService : CoroutineIntentService() {
|
||||
.build()
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setDefaults(0)
|
||||
@@ -97,7 +98,7 @@ class AutoFixService : CoroutineIntentService() {
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.addAction(
|
||||
appcompatR.drawable.abc_ic_clear_material,
|
||||
applicationContext.getString(android.R.string.cancel),
|
||||
getString(android.R.string.cancel),
|
||||
jobContext.getCancelIntent(),
|
||||
)
|
||||
.build()
|
||||
@@ -110,7 +111,7 @@ class AutoFixService : CoroutineIntentService() {
|
||||
}
|
||||
|
||||
private suspend fun buildNotification(startId: Int, result: Result<Pair<Manga, Manga?>>): Notification {
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setDefaults(0)
|
||||
.setSilent(true)
|
||||
@@ -119,17 +120,17 @@ class AutoFixService : CoroutineIntentService() {
|
||||
if (replacement != null) {
|
||||
notification.setLargeIcon(
|
||||
coil.execute(
|
||||
ImageRequest.Builder(applicationContext)
|
||||
ImageRequest.Builder(this)
|
||||
.data(replacement.coverUrl)
|
||||
.mangaSourceExtra(replacement.source)
|
||||
.build(),
|
||||
).toBitmapOrNull(),
|
||||
)
|
||||
notification.setSubText(replacement.title)
|
||||
val intent = AppRouter.detailsIntent(applicationContext, replacement)
|
||||
val intent = AppRouter.detailsIntent(this, replacement)
|
||||
notification.setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
applicationContext,
|
||||
this,
|
||||
replacement.id.toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
@@ -143,35 +144,35 @@ class AutoFixService : CoroutineIntentService() {
|
||||
},
|
||||
)
|
||||
notification
|
||||
.setContentTitle(applicationContext.getString(R.string.fixed))
|
||||
.setContentTitle(getString(R.string.fixed))
|
||||
.setContentText(
|
||||
applicationContext.getString(
|
||||
getString(
|
||||
R.string.manga_replaced,
|
||||
seed.title,
|
||||
seed.source.getTitle(applicationContext),
|
||||
seed.source.getTitle(this),
|
||||
replacement.title,
|
||||
replacement.source.getTitle(applicationContext),
|
||||
replacement.source.getTitle(this),
|
||||
),
|
||||
)
|
||||
.setSmallIcon(R.drawable.ic_stat_done)
|
||||
} else {
|
||||
notification
|
||||
.setContentTitle(applicationContext.getString(R.string.fixing_manga))
|
||||
.setContentText(applicationContext.getString(R.string.no_fix_required, seed.title))
|
||||
.setContentTitle(getString(R.string.fixing_manga))
|
||||
.setContentText(getString(R.string.no_fix_required, seed.title))
|
||||
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
notification
|
||||
.setContentTitle(applicationContext.getString(R.string.error_occurred))
|
||||
.setContentTitle(getString(R.string.error_occurred))
|
||||
.setContentText(
|
||||
if (error is AutoFixUseCase.NoAlternativesException) {
|
||||
applicationContext.getString(R.string.no_alternatives_found, error.seed.manga.title)
|
||||
if (error is NoAlternativesException) {
|
||||
getString(R.string.no_alternatives_found, error.seed.manga.title)
|
||||
} else {
|
||||
error.getDisplayMessage(applicationContext.resources)
|
||||
error.getDisplayMessage(resources)
|
||||
},
|
||||
).setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
ErrorReporterReceiver.getNotificationAction(
|
||||
context = applicationContext,
|
||||
context = this,
|
||||
e = error,
|
||||
notificationId = startId,
|
||||
notificationTag = TAG,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.backups.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
@@ -27,24 +28,13 @@ abstract class BaseBackupRestoreService : CoroutineIntentService() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||
createNotificationChannel()
|
||||
createNotificationChannel(this)
|
||||
}
|
||||
|
||||
override fun IntentJobContext.onError(error: Throwable) {
|
||||
showResultNotification(null, CompositeResult.failure(error))
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||
.setName(getString(R.string.backup_restore))
|
||||
.setShowBadge(true)
|
||||
.setVibrationEnabled(false)
|
||||
.setSound(null, null)
|
||||
.setLightsEnabled(false)
|
||||
.build()
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
protected fun IntentJobContext.showResultNotification(
|
||||
fileUri: Uri?,
|
||||
result: CompositeResult,
|
||||
@@ -128,8 +118,19 @@ abstract class BaseBackupRestoreService : CoroutineIntentService() {
|
||||
.setBigContentTitle(title),
|
||||
)
|
||||
|
||||
protected companion object {
|
||||
companion object {
|
||||
|
||||
const val CHANNEL_ID = "backup_restore"
|
||||
|
||||
fun createNotificationChannel(context: Context) {
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||
.setName(context.getString(R.string.backup_restore))
|
||||
.setShowBadge(true)
|
||||
.setVibrationEnabled(false)
|
||||
.setSound(null, null)
|
||||
.setLightsEnabled(false)
|
||||
.build()
|
||||
NotificationManagerCompat.from(context).createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
package org.koitharu.kotatsu.backups.ui.periodical
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||
import org.koitharu.kotatsu.backups.domain.BackupUtils
|
||||
import org.koitharu.kotatsu.backups.domain.ExternalBackupStorage
|
||||
import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService
|
||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import java.util.zip.ZipOutputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -40,7 +49,7 @@ class PeriodicalBackupService : CoroutineIntentService() {
|
||||
}
|
||||
externalBackupStorage.put(output)
|
||||
externalBackupStorage.trim(settings.periodicalBackupMaxCount)
|
||||
if (settings.isBackupTelegramUploadEnabled) {
|
||||
if (settings.isBackupTelegramUploadEnabled && telegramBackupUploader.isAvailable) {
|
||||
telegramBackupUploader.uploadBackup(output)
|
||||
}
|
||||
} finally {
|
||||
@@ -48,5 +57,49 @@ class PeriodicalBackupService : CoroutineIntentService() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun IntentJobContext.onError(error: Throwable) = Unit
|
||||
override fun IntentJobContext.onError(error: Throwable) {
|
||||
if (!applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
return
|
||||
}
|
||||
BaseBackupRestoreService.createNotificationChannel(applicationContext)
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setDefaults(0)
|
||||
.setSilent(true)
|
||||
.setAutoCancel(true)
|
||||
val title = getString(R.string.periodic_backups)
|
||||
val message = getString(
|
||||
R.string.inline_preference_pattern,
|
||||
getString(R.string.packup_creation_failed),
|
||||
error.getDisplayMessage(resources),
|
||||
)
|
||||
notification
|
||||
.setContentText(message)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setStyle(
|
||||
NotificationCompat.BigTextStyle()
|
||||
.bigText(message)
|
||||
.setSummaryText(getString(R.string.packup_creation_failed))
|
||||
.setBigContentTitle(title),
|
||||
)
|
||||
ErrorReporterReceiver.getNotificationAction(applicationContext, error, startId, TAG)?.let { action ->
|
||||
notification.addAction(action)
|
||||
}
|
||||
notification.setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
applicationContext,
|
||||
0,
|
||||
AppRouter.periodicBackupSettingsIntent(applicationContext),
|
||||
0,
|
||||
false,
|
||||
),
|
||||
)
|
||||
NotificationManagerCompat.from(applicationContext).notify(TAG, startId, notification.build())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val CHANNEL_ID = BaseBackupRestoreService.CHANNEL_ID
|
||||
const val TAG = "periodical_backup"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.activity.result.ActivityResultCallback
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceCategory
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -37,6 +38,7 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_backup_periodic)
|
||||
findPreference<PreferenceCategory>(AppSettings.KEY_BACKUP_TG)?.isVisible = viewModel.isTelegramAvailable
|
||||
findPreference<EditTextPreference>(AppSettings.KEY_BACKUP_TG_CHAT)?.summaryProvider =
|
||||
EditTextFallbackSummaryProvider(R.string.telegram_chat_id_summary)
|
||||
}
|
||||
@@ -84,6 +86,11 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
|
||||
"" -> null
|
||||
else -> path
|
||||
}
|
||||
preference.icon = if (path == null) {
|
||||
getWarningIcon()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindLastBackupInfo(lastBackupDate: Date?) {
|
||||
|
||||
@@ -27,6 +27,9 @@ class PeriodicalBackupSettingsViewModel @Inject constructor(
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val isTelegramAvailable
|
||||
get() = telegramUploader.isAvailable
|
||||
|
||||
val lastBackupDate = MutableStateFlow<Date?>(null)
|
||||
val backupsDirectory = MutableStateFlow<String?>("")
|
||||
val isTelegramCheckLoading = MutableStateFlow(false)
|
||||
|
||||
@@ -30,6 +30,9 @@ class TelegramBackupUploader @Inject constructor(
|
||||
|
||||
private val botToken = context.getString(R.string.tg_backup_bot_token)
|
||||
|
||||
val isAvailable: Boolean
|
||||
get() = botToken.isNotEmpty()
|
||||
|
||||
suspend fun uploadBackup(file: File) {
|
||||
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
|
||||
val multipartBody = MultipartBody.Builder()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.browser
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Looper
|
||||
import android.webkit.WebResourceRequest
|
||||
@@ -15,7 +16,7 @@ import java.io.ByteArrayInputStream
|
||||
|
||||
open class BrowserClient(
|
||||
private val callback: BrowserCallback,
|
||||
private val adBlock: AdBlock,
|
||||
private val adBlock: AdBlock?,
|
||||
) : WebViewClient() {
|
||||
|
||||
/**
|
||||
@@ -47,7 +48,7 @@ open class BrowserClient(
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView?,
|
||||
url: String?
|
||||
): WebResourceResponse? = if (url.isNullOrEmpty() || adBlock.shouldLoadUrl(url, view?.getUrlSafe())) {
|
||||
): WebResourceResponse? = if (url.isNullOrEmpty() || adBlock?.shouldLoadUrl(url, view?.getUrlSafe()) ?: true) {
|
||||
super.shouldInterceptRequest(view, url)
|
||||
} else {
|
||||
emptyResponse()
|
||||
@@ -57,15 +58,17 @@ open class BrowserClient(
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?
|
||||
): WebResourceResponse? = if (request == null || adBlock.shouldLoadUrl(request.url.toString(), view?.getUrlSafe())) {
|
||||
super.shouldInterceptRequest(view, request)
|
||||
} else {
|
||||
emptyResponse()
|
||||
}
|
||||
): WebResourceResponse? =
|
||||
if (request == null || adBlock?.shouldLoadUrl(request.url.toString(), view?.getUrlSafe()) ?: true) {
|
||||
super.shouldInterceptRequest(view, request)
|
||||
} else {
|
||||
emptyResponse()
|
||||
}
|
||||
|
||||
private fun emptyResponse(): WebResourceResponse =
|
||||
WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(byteArrayOf()))
|
||||
|
||||
@SuppressLint("WrongThread")
|
||||
@AnyThread
|
||||
private fun WebView.getUrlSafe(): String? = if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
url
|
||||
|
||||
@@ -42,18 +42,21 @@ import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
|
||||
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
||||
import org.koitharu.kotatsu.core.util.AcraScreenLogger
|
||||
import org.koitharu.kotatsu.core.util.FileSize
|
||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
||||
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageFetcher
|
||||
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
import org.koitharu.kotatsu.local.data.FaviconCache
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageCache
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.data.PageCache
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
||||
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
|
||||
@@ -101,7 +104,7 @@ interface AppModule {
|
||||
fun provideCoil(
|
||||
@LocalizedAppContext context: Context,
|
||||
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
faviconFetcherFactory: FaviconFetcher.Factory,
|
||||
imageProxyInterceptor: ImageProxyInterceptor,
|
||||
pageFetcherFactory: MangaPageFetcher.Factory,
|
||||
coverRestoreInterceptor: CoverRestoreInterceptor,
|
||||
@@ -138,7 +141,7 @@ interface AppModule {
|
||||
add(SvgDecoder.Factory())
|
||||
add(CbzFetcher.Factory())
|
||||
add(AvifImageDecoder.Factory())
|
||||
add(FaviconFetcher.Factory(mangaRepositoryFactory))
|
||||
add(faviconFetcherFactory)
|
||||
add(MangaPageKeyer())
|
||||
add(pageFetcherFactory)
|
||||
add(imageProxyInterceptor)
|
||||
@@ -195,5 +198,29 @@ interface AppModule {
|
||||
fun provideWorkManager(
|
||||
@ApplicationContext context: Context,
|
||||
): WorkManager = WorkManager.getInstance(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@PageCache
|
||||
fun providePageCache(
|
||||
@ApplicationContext context: Context,
|
||||
) = LocalStorageCache(
|
||||
context = context,
|
||||
dir = CacheDir.PAGES,
|
||||
defaultSize = FileSize.MEGABYTES.convert(200, FileSize.BYTES),
|
||||
minSize = FileSize.MEGABYTES.convert(20, FileSize.BYTES),
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@FaviconCache
|
||||
fun provideFaviconCache(
|
||||
@ApplicationContext context: Context,
|
||||
) = LocalStorageCache(
|
||||
context = context,
|
||||
dir = CacheDir.FAVICONS,
|
||||
defaultSize = FileSize.MEGABYTES.convert(8, FileSize.BYTES),
|
||||
minSize = FileSize.MEGABYTES.convert(2, FileSize.BYTES),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.internal.platform.PlatformRegistry
|
||||
import org.acra.ACRA
|
||||
import org.acra.ReportField
|
||||
import org.acra.config.dialog
|
||||
@@ -79,6 +80,7 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
PlatformRegistry.applicationContext = this // TODO replace with OkHttp.initialize
|
||||
if (ACRA.isACRASenderServiceProcess()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.BadParcelableException
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
@@ -65,7 +64,7 @@ class ErrorReporterReceiver : BroadcastReceiver() {
|
||||
e: Throwable,
|
||||
notificationId: Int,
|
||||
notificationTag: String?,
|
||||
): PendingIntent? = try {
|
||||
): PendingIntent? = runCatching {
|
||||
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
||||
intent.setAction(ACTION_REPORT)
|
||||
intent.setData("err://${e.hashCode()}".toUri())
|
||||
@@ -73,9 +72,9 @@ class ErrorReporterReceiver : BroadcastReceiver() {
|
||||
intent.putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
intent.putExtra(EXTRA_NOTIFICATION_TAG, notificationTag)
|
||||
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
|
||||
} catch (e: BadParcelableException) {
|
||||
}.onFailure { e ->
|
||||
// probably cannot write exception as serializable
|
||||
e.printStackTraceDebug()
|
||||
null
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,15 @@ import android.app.Notification
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.annotation.CheckResult
|
||||
import androidx.annotation.RequiresPermission
|
||||
import androidx.collection.MutableScatterMap
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import coil3.EventListener
|
||||
@@ -26,6 +25,7 @@ import coil3.request.allowConversionToBitmap
|
||||
import coil3.request.allowHardware
|
||||
import coil3.request.lifecycle
|
||||
import coil3.size.Scale
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
@@ -44,6 +44,7 @@ import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
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.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||
@@ -55,6 +56,7 @@ import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||
import org.koitharu.kotatsu.parsers.util.mapToArray
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
@@ -65,27 +67,13 @@ class CaptchaHandler @Inject constructor(
|
||||
@LocalizedAppContext private val context: Context,
|
||||
private val databaseProvider: Provider<MangaDatabase>,
|
||||
private val coilProvider: Provider<ImageLoader>,
|
||||
private val webViewExecutor: WebViewExecutor,
|
||||
) : EventListener() {
|
||||
|
||||
private val exceptionMap = MutableScatterMap<MangaSource, CloudFlareProtectedException>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
init {
|
||||
ContextCompat.registerReceiver(
|
||||
context,
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val sourceName = intent?.getStringExtra(AppRouter.KEY_SOURCE) ?: return
|
||||
goAsync {
|
||||
handleException(MangaSource(sourceName), exception = null, notify = false)
|
||||
}
|
||||
}
|
||||
},
|
||||
IntentFilter().apply { addAction(ACTION_DISCARD) },
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
}
|
||||
|
||||
@CheckResult
|
||||
suspend fun handle(exception: CloudFlareException): Boolean = handleException(exception.source, exception, true)
|
||||
|
||||
suspend fun discard(source: MangaSource) {
|
||||
@@ -95,10 +83,18 @@ class CaptchaHandler @Inject constructor(
|
||||
override fun onError(request: ImageRequest, result: ErrorResult) {
|
||||
super.onError(request, result)
|
||||
val e = result.throwable
|
||||
if (e is CloudFlareException && request.extras[ignoreCaptchaKey] != true) {
|
||||
if (e is CloudFlareException) {
|
||||
val scope = request.lifecycle?.coroutineScope ?: processLifecycleScope
|
||||
scope.launch {
|
||||
handleException(e.source, e, true)
|
||||
if (
|
||||
handleException(
|
||||
source = e.source,
|
||||
exception = e,
|
||||
notify = request.extras[suppressCaptchaKey] != true,
|
||||
)
|
||||
) {
|
||||
coilProvider.get().enqueue(request) // TODO check if ok
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,11 +102,14 @@ class CaptchaHandler @Inject constructor(
|
||||
private suspend fun handleException(
|
||||
source: MangaSource,
|
||||
exception: CloudFlareException?,
|
||||
notify: Boolean
|
||||
notify: Boolean,
|
||||
): Boolean = withContext(Dispatchers.Default) {
|
||||
if (source == UnknownMangaSource) {
|
||||
return@withContext false
|
||||
}
|
||||
if (exception != null && webViewExecutor.tryResolveCaptcha(exception, RESOLVE_TIMEOUT)) {
|
||||
return@withContext true
|
||||
}
|
||||
mutex.withLock {
|
||||
var removedException: CloudFlareProtectedException? = null
|
||||
if (exception is CloudFlareProtectedException) {
|
||||
@@ -121,21 +120,21 @@ class CaptchaHandler @Inject constructor(
|
||||
val dao = databaseProvider.get().getSourcesDao()
|
||||
dao.setCfState(source.name, exception?.state ?: CloudFlareHelper.PROTECTION_NOT_DETECTED)
|
||||
|
||||
val exceptions = dao.findAllCaptchaRequired().mapNotNull {
|
||||
it.source.toMangaSourceOrNull()
|
||||
}.filterNot {
|
||||
SourceSettings(context, it).isCaptchaNotificationsDisabled
|
||||
}.mapNotNull {
|
||||
exceptionMap[it]
|
||||
}
|
||||
if (notify && context.checkNotificationPermission(CHANNEL_ID)) {
|
||||
val exceptions = dao.findAllCaptchaRequired().mapNotNull {
|
||||
it.source.toMangaSourceOrNull()
|
||||
}.filterNot {
|
||||
SourceSettings(context, it).isCaptchaNotificationsDisabled
|
||||
}.mapNotNull {
|
||||
exceptionMap[it]
|
||||
}
|
||||
if (removedException != null) {
|
||||
NotificationManagerCompat.from(context).cancel(TAG, removedException.source.hashCode())
|
||||
}
|
||||
notify(exceptions)
|
||||
}
|
||||
}
|
||||
true
|
||||
false
|
||||
}
|
||||
|
||||
@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
@@ -169,6 +168,15 @@ class CaptchaHandler @Inject constructor(
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSmallIcon(R.drawable.ic_bot)
|
||||
.setGroup(GROUP_CAPTCHA)
|
||||
.setContentIntent(
|
||||
PendingIntentCompat.getActivities(
|
||||
context, GROUP_NOTIFICATION_ID,
|
||||
exceptions.mapToArray { e ->
|
||||
AppRouter.cloudFlareResolveIntent(context, e)
|
||||
},
|
||||
0, false,
|
||||
),
|
||||
)
|
||||
.setContentText(
|
||||
context.getString(
|
||||
R.string.captcha_required_summary, context.getString(R.string.app_name),
|
||||
@@ -189,7 +197,6 @@ class CaptchaHandler @Inject constructor(
|
||||
|
||||
private suspend fun buildNotification(exception: CloudFlareProtectedException): Notification {
|
||||
val intent = AppRouter.cloudFlareResolveIntent(context, exception)
|
||||
.setData(exception.url.toUri())
|
||||
val discardIntent = Intent(ACTION_DISCARD)
|
||||
.putExtra(AppRouter.KEY_SOURCE, exception.source.name)
|
||||
.setData("source://${exception.source.name}".toUri())
|
||||
@@ -242,6 +249,7 @@ class CaptchaHandler @Inject constructor(
|
||||
.data(source.faviconUri())
|
||||
.allowHardware(false)
|
||||
.allowConversionToBitmap(true)
|
||||
.suppressCaptchaErrors()
|
||||
.mangaSourceExtra(source)
|
||||
.size(context.resources.getNotificationIconSize())
|
||||
.scale(Scale.FILL)
|
||||
@@ -251,13 +259,27 @@ class CaptchaHandler @Inject constructor(
|
||||
it.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DiscardReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject
|
||||
lateinit var captchaHandler: CaptchaHandler
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val sourceName = intent?.getStringExtra(AppRouter.KEY_SOURCE) ?: return
|
||||
goAsync {
|
||||
captchaHandler.handleException(MangaSource(sourceName), exception = null, notify = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun ImageRequest.Builder.ignoreCaptchaErrors() = apply {
|
||||
extras[ignoreCaptchaKey] = true
|
||||
fun ImageRequest.Builder.suppressCaptchaErrors() = apply {
|
||||
extras[suppressCaptchaKey] = true
|
||||
}
|
||||
|
||||
val ignoreCaptchaKey = Extras.Key(false)
|
||||
private val suppressCaptchaKey = Extras.Key(false)
|
||||
|
||||
private const val CHANNEL_ID = "captcha"
|
||||
private const val TAG = CHANNEL_ID
|
||||
@@ -265,5 +287,6 @@ class CaptchaHandler @Inject constructor(
|
||||
private const val GROUP_NOTIFICATION_ID = 34
|
||||
private const val SETTINGS_ACTION_CODE = 3
|
||||
private const val ACTION_DISCARD = "org.koitharu.kotatsu.CAPTCHA_DISCARD"
|
||||
private const val RESOLVE_TIMEOUT = 20_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.image
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.network.httpHeaders
|
||||
import coil3.request.ImageResult
|
||||
import org.koitharu.kotatsu.core.model.unwrap
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceKey
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
@@ -10,7 +11,7 @@ import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
class MangaSourceHeaderInterceptor : Interceptor {
|
||||
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val mangaSource = chain.request.extras[mangaSourceKey] as? MangaParserSource ?: return chain.proceed()
|
||||
val mangaSource = chain.request.extras[mangaSourceKey]?.unwrap() as? MangaParserSource ?: return chain.proceed()
|
||||
val request = chain.request
|
||||
val newHeaders = request.httpHeaders.newBuilder()
|
||||
.set(CommonHeaders.MANGA_SOURCE, mangaSource.name)
|
||||
|
||||
@@ -55,6 +55,7 @@ val MangaState.titleResId: Int
|
||||
MangaState.ABANDONED -> R.string.state_abandoned
|
||||
MangaState.PAUSED -> R.string.state_paused
|
||||
MangaState.UPCOMING -> R.string.state_upcoming
|
||||
MangaState.RESTRICTED -> R.string.unavailable
|
||||
}
|
||||
|
||||
@get:DrawableRes
|
||||
@@ -65,6 +66,7 @@ val MangaState.iconResId: Int
|
||||
MangaState.ABANDONED -> R.drawable.ic_state_abandoned
|
||||
MangaState.PAUSED -> R.drawable.ic_action_pause
|
||||
MangaState.UPCOMING -> materialR.drawable.ic_clock_black_24dp
|
||||
MangaState.RESTRICTED -> R.drawable.ic_disable
|
||||
}
|
||||
|
||||
@get:StringRes
|
||||
|
||||
@@ -28,11 +28,15 @@ data object UnknownMangaSource : MangaSource {
|
||||
override val name = "UNKNOWN"
|
||||
}
|
||||
|
||||
data object TestMangaSource : MangaSource {
|
||||
override val name = "TEST"
|
||||
}
|
||||
|
||||
fun MangaSource(name: String?): MangaSource {
|
||||
when (name ?: return UnknownMangaSource) {
|
||||
UnknownMangaSource.name -> return UnknownMangaSource
|
||||
|
||||
LocalMangaSource.name -> return LocalMangaSource
|
||||
TestMangaSource.name -> return TestMangaSource
|
||||
}
|
||||
if (name.startsWith("content:")) {
|
||||
val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownMangaSource
|
||||
@@ -92,6 +96,7 @@ fun MangaSource.getSummary(context: Context): String? = when (val source = unwra
|
||||
fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()) {
|
||||
is MangaParserSource -> source.title
|
||||
LocalMangaSource -> context.getString(R.string.local_storage)
|
||||
TestMangaSource -> context.getString(R.string.test_parser)
|
||||
is ExternalMangaSource -> source.resolveName(context)
|
||||
else -> context.getString(R.string.unknown)
|
||||
}
|
||||
|
||||
@@ -281,6 +281,10 @@ class AppRouter private constructor(
|
||||
startActivity(sourcesSettingsIntent(contextOrNull() ?: return))
|
||||
}
|
||||
|
||||
fun openDiscordSettings() {
|
||||
startActivity(discordSettingsIntent(contextOrNull() ?: return))
|
||||
}
|
||||
|
||||
fun openReaderTapGridSettings() = startActivity(ReaderTapGridConfigActivity::class.java)
|
||||
|
||||
fun openScrobblerSettings(scrobbler: ScrobblerService) {
|
||||
@@ -571,7 +575,7 @@ class AppRouter private constructor(
|
||||
/** Public utils **/
|
||||
|
||||
fun isFilterSupported(): Boolean = when {
|
||||
fragment != null -> fragment.activity is FilterCoordinator.Owner
|
||||
fragment != null -> FilterCoordinator.find(fragment) != null
|
||||
activity != null -> activity is FilterCoordinator.Owner
|
||||
else -> false
|
||||
}
|
||||
@@ -741,6 +745,14 @@ class AppRouter private constructor(
|
||||
Intent(context, SettingsActivity::class.java)
|
||||
.setAction(ACTION_TRACKER)
|
||||
|
||||
fun periodicBackupSettingsIntent(context: Context) =
|
||||
Intent(context, SettingsActivity::class.java)
|
||||
.setAction(ACTION_PERIODIC_BACKUP)
|
||||
|
||||
fun discordSettingsIntent(context: Context) =
|
||||
Intent(context, SettingsActivity::class.java)
|
||||
.setAction(ACTION_MANAGE_DISCORD)
|
||||
|
||||
fun proxySettingsIntent(context: Context) =
|
||||
Intent(context, SettingsActivity::class.java)
|
||||
.setAction(ACTION_PROXY)
|
||||
@@ -800,6 +812,7 @@ class AppRouter private constructor(
|
||||
const val KEY_FILTER = "filter"
|
||||
const val KEY_ID = "id"
|
||||
const val KEY_INDEX = "index"
|
||||
const val KEY_IS_BOTTOMTAB = "is_btab"
|
||||
const val KEY_KIND = "kind"
|
||||
const val KEY_LIST_SECTION = "list_section"
|
||||
const val KEY_MANGA = "manga"
|
||||
@@ -823,8 +836,10 @@ class AppRouter private constructor(
|
||||
const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
|
||||
const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
|
||||
const val ACTION_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES"
|
||||
const val ACTION_MANAGE_DISCORD = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DISCORD"
|
||||
const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS"
|
||||
const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER"
|
||||
const val ACTION_PERIODIC_BACKUP = "${BuildConfig.APPLICATION_ID}.action.MANAGE_PERIODIC_BACKUP"
|
||||
|
||||
private const val ACCOUNT_KEY = "account"
|
||||
private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.util.Log
|
||||
import dagger.Lazy
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Interceptor
|
||||
@@ -36,7 +35,8 @@ class CommonHeadersInterceptor @Inject constructor(
|
||||
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
|
||||
} else {
|
||||
if (BuildConfig.DEBUG && source == null) {
|
||||
Log.w("Http", "Request without source tag: ${request.url}")
|
||||
IllegalArgumentException("Request without source tag: ${request.url}")
|
||||
.printStackTrace()
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.annotation.WorkerThread
|
||||
import androidx.core.util.Predicate
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl
|
||||
import org.koitharu.kotatsu.parsers.util.newBuilder
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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,15 +2,22 @@ package org.koitharu.kotatsu.core.network.webview
|
||||
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class ContinuationResumeWebViewClient(
|
||||
open class ContinuationResumeWebViewClient(
|
||||
private val continuation: Continuation<Unit>,
|
||||
) : WebViewClient() {
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
view?.webViewClient = WebViewClient() // reset to default
|
||||
continuation.resume(Unit)
|
||||
resumeContinuation(view)
|
||||
}
|
||||
|
||||
protected fun resumeContinuation(view: WebView?) {
|
||||
if (continuation !is CancellableContinuation || continuation.isActive) {
|
||||
view?.webViewClient = WebViewClient() // reset to default
|
||||
continuation.resume(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package org.koitharu.kotatsu.core.network.webview
|
||||
|
||||
import android.content.Context
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.annotation.MainThread
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
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.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.lang.ref.WeakReference
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
@Singleton
|
||||
class WebViewExecutor @Inject constructor(
|
||||
@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 val mutex = Mutex()
|
||||
|
||||
val defaultUserAgent: String? by lazy {
|
||||
WebSettings.getDefaultUserAgent(context)
|
||||
}
|
||||
|
||||
suspend fun evaluateJs(baseUrl: String?, script: String): String? = mutex.withLock {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
val webView = obtainWebView()
|
||||
try {
|
||||
if (!baseUrl.isNullOrEmpty()) {
|
||||
suspendCoroutine { cont ->
|
||||
webView.webViewClient = ContinuationResumeWebViewClient(cont)
|
||||
webView.loadDataWithBaseURL(baseUrl, " ", "text/html", null, null)
|
||||
}
|
||||
}
|
||||
suspendCoroutine { cont ->
|
||||
webView.evaluateJavascript(script) { result ->
|
||||
cont.resume(result?.takeUnless { it == "null" })
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
webView.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun tryResolveCaptcha(exception: CloudFlareException, timeout: Long): Boolean = mutex.withLock {
|
||||
runCatchingCancellable {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
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 ->
|
||||
exception.addSuppressed(e)
|
||||
e.printStackTraceDebug()
|
||||
}.isSuccess
|
||||
}
|
||||
|
||||
private suspend fun obtainWebView(): WebView {
|
||||
webViewCached?.get()?.let {
|
||||
return it
|
||||
}
|
||||
return withContext(Dispatchers.Main.immediate) {
|
||||
webViewCached?.get()?.let {
|
||||
return@withContext it
|
||||
}
|
||||
WebView(context).also {
|
||||
it.configureForParser(null)
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,7 @@ class AppShortcutManager @Inject constructor(
|
||||
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
|
||||
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
|
||||
)
|
||||
mangaRepository.storeManga(manga)
|
||||
mangaRepository.storeManga(manga, replaceExisting = true)
|
||||
val title = manga.title.ifEmpty {
|
||||
manga.altTitles.firstOrNull()
|
||||
}.ifNullOrEmpty {
|
||||
@@ -173,6 +173,7 @@ class AppShortcutManager @Inject constructor(
|
||||
coil.execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(source.faviconUri())
|
||||
.mangaSourceExtra(source)
|
||||
.size(iconSize)
|
||||
.scale(Scale.FIT)
|
||||
.build(),
|
||||
|
||||
@@ -80,12 +80,7 @@ class NetworkState(
|
||||
if (settings.isOfflineCheckDisabled) {
|
||||
return true
|
||||
}
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
activeNetwork?.let { isOnline(it) } == true
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
activeNetworkInfo?.isConnected == true
|
||||
}
|
||||
return activeNetwork?.let { isOnline(it) } == true
|
||||
}
|
||||
|
||||
private fun ConnectivityManager.isOnline(network: Network): Boolean {
|
||||
|
||||
@@ -13,7 +13,6 @@ import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
|
||||
// https://stackoverflow.com/questions/77555641/saf-no-activity-found-to-handle-intent-android-intent-action-open-document-tr
|
||||
class OpenDocumentTreeHelper(
|
||||
@@ -28,38 +27,42 @@ class OpenDocumentTreeHelper(
|
||||
callback,
|
||||
)
|
||||
|
||||
private val pickFileTreeLauncherQ = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
activityResultCaller.registerForActivityResult(OpenDocumentTreeContractQ(flags), callback)
|
||||
private val pickFileTreeLauncherPrimaryStorage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
activityResultCaller.registerForActivityResult(OpenDocumentTreeContractPrimaryStorage(flags), callback)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
private val pickFileTreeLauncherLegacy = activityResultCaller.registerForActivityResult(
|
||||
contract = OpenDocumentTreeContractLegacy(flags),
|
||||
private val pickFileTreeLauncherDefault = activityResultCaller.registerForActivityResult(
|
||||
contract = OpenDocumentTreeContractDefault(flags),
|
||||
callback = callback,
|
||||
)
|
||||
|
||||
override fun launch(input: Uri?, options: ActivityOptionsCompat?) {
|
||||
if (pickFileTreeLauncherQ == null) {
|
||||
pickFileTreeLauncherLegacy.launch(input, options)
|
||||
return
|
||||
}
|
||||
try {
|
||||
pickFileTreeLauncherQ.launch(input, options)
|
||||
pickFileTreeLauncherDefault.launch(input, options)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTraceDebug()
|
||||
pickFileTreeLauncherLegacy.launch(input, options)
|
||||
if (pickFileTreeLauncherPrimaryStorage != null) {
|
||||
try {
|
||||
pickFileTreeLauncherPrimaryStorage.launch(input, options)
|
||||
} catch (e2: Exception) {
|
||||
e.addSuppressed(e2)
|
||||
throw e
|
||||
}
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun unregister() {
|
||||
pickFileTreeLauncherQ?.unregister()
|
||||
pickFileTreeLauncherLegacy.unregister()
|
||||
pickFileTreeLauncherPrimaryStorage?.unregister()
|
||||
pickFileTreeLauncherDefault.unregister()
|
||||
}
|
||||
|
||||
override val contract: ActivityResultContract<Uri?, *>
|
||||
get() = pickFileTreeLauncherQ?.contract ?: pickFileTreeLauncherLegacy.contract
|
||||
get() = pickFileTreeLauncherPrimaryStorage?.contract ?: pickFileTreeLauncherDefault.contract
|
||||
|
||||
private open class OpenDocumentTreeContractLegacy(
|
||||
private open class OpenDocumentTreeContractDefault(
|
||||
private val flags: Int,
|
||||
) : ActivityResultContracts.OpenDocumentTree() {
|
||||
|
||||
@@ -71,9 +74,9 @@ class OpenDocumentTreeHelper(
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private class OpenDocumentTreeContractQ(
|
||||
private class OpenDocumentTreeContractPrimaryStorage(
|
||||
private val flags: Int,
|
||||
) : OpenDocumentTreeContractLegacy(flags) {
|
||||
) : OpenDocumentTreeContractDefault(flags) {
|
||||
|
||||
override fun createIntent(context: Context, input: Uri?): Intent {
|
||||
val intent = (context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager)
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
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.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
|
||||
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
|
||||
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 searchQueryCapabilities: MangaSearchQueryCapabilities
|
||||
get() = MangaSearchQueryCapabilities()
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||
|
||||
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
|
||||
|
||||
override suspend fun getList(query: MangaSearchQuery): 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 java.util.EnumSet
|
||||
|
||||
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
||||
open class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
@@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
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.entity.ContentRating
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
||||
@@ -41,7 +43,7 @@ class MangaDataRepository @Inject constructor(
|
||||
|
||||
suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) {
|
||||
db.withTransaction {
|
||||
storeManga(manga)
|
||||
storeManga(manga, replaceExisting = false)
|
||||
val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id)
|
||||
db.getPreferencesDao().upsert(entity.copy(mode = mode.id))
|
||||
}
|
||||
@@ -49,7 +51,7 @@ class MangaDataRepository @Inject constructor(
|
||||
|
||||
suspend fun saveColorFilter(manga: Manga, colorFilter: ReaderColorFilter?) {
|
||||
db.withTransaction {
|
||||
storeManga(manga)
|
||||
storeManga(manga, replaceExisting = false)
|
||||
val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id)
|
||||
db.getPreferencesDao().upsert(
|
||||
entity.copy(
|
||||
@@ -87,10 +89,11 @@ class MangaDataRepository @Inject constructor(
|
||||
return map
|
||||
}
|
||||
|
||||
suspend fun setOverride(mangaId: Long, override: MangaOverride?) {
|
||||
suspend fun setOverride(manga: Manga, override: MangaOverride?) {
|
||||
db.withTransaction {
|
||||
storeManga(manga, replaceExisting = false)
|
||||
val dao = db.getPreferencesDao()
|
||||
val entity = dao.find(mangaId) ?: newEntity(mangaId)
|
||||
val entity = dao.find(manga.id) ?: newEntity(manga.id)
|
||||
dao.upsert(
|
||||
entity.copy(
|
||||
titleOverride = override?.title?.nullIfEmpty(),
|
||||
@@ -127,7 +130,10 @@ class MangaDataRepository @Inject constructor(
|
||||
else -> null
|
||||
}
|
||||
|
||||
suspend fun storeManga(manga: Manga) {
|
||||
suspend fun storeManga(manga: Manga, replaceExisting: Boolean) {
|
||||
if (!replaceExisting && db.getMangaDao().find(manga.id) != null) {
|
||||
return
|
||||
}
|
||||
db.withTransaction {
|
||||
// avoid storing local manga if remote one is already stored
|
||||
val existing = if (manga.isLocal) {
|
||||
@@ -185,7 +191,12 @@ class MangaDataRepository @Inject constructor(
|
||||
emitInitialState = emitInitialState,
|
||||
)
|
||||
|
||||
private suspend fun Manga.withCachedChaptersIfNeeded(flag: Boolean): Manga = if (flag && chapters.isNullOrEmpty()) {
|
||||
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()) {
|
||||
val cachedChapters = db.getChaptersDao().findAll(id)
|
||||
if (cachedChapters.isEmpty()) {
|
||||
this
|
||||
|
||||
@@ -3,15 +3,8 @@ package org.koitharu.kotatsu.core.parser
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import android.webkit.WebView
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -22,11 +15,8 @@ import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
||||
import org.koitharu.kotatsu.core.image.BitmapDecoderCompat
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.network.webview.ContinuationResumeWebViewClient
|
||||
import org.koitharu.kotatsu.core.network.webview.WebViewExecutor
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
|
||||
import org.koitharu.kotatsu.core.util.ext.toList
|
||||
import org.koitharu.kotatsu.core.util.ext.toMimeType
|
||||
import org.koitharu.kotatsu.core.util.ext.use
|
||||
@@ -37,25 +27,19 @@ import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import org.koitharu.kotatsu.parsers.util.map
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
@Singleton
|
||||
class MangaLoaderContextImpl @Inject constructor(
|
||||
@MangaHttpClient override val httpClient: OkHttpClient,
|
||||
override val cookieJar: MutableCookieJar,
|
||||
@ApplicationContext private val androidContext: Context,
|
||||
private val webViewExecutor: WebViewExecutor,
|
||||
) : MangaLoaderContext() {
|
||||
|
||||
private var webViewCached: WeakReference<WebView>? = null
|
||||
private val webViewUserAgent by lazy { obtainWebViewUserAgent() }
|
||||
private val jsMutex = Mutex()
|
||||
private val jsTimeout = TimeUnit.SECONDS.toMillis(4)
|
||||
|
||||
@Deprecated("Provide a base url")
|
||||
@@ -63,25 +47,10 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
override suspend fun evaluateJs(script: String): String? = evaluateJs("", script)
|
||||
|
||||
override suspend fun evaluateJs(baseUrl: String, script: String): String? = withTimeout(jsTimeout) {
|
||||
jsMutex.withLock {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
val webView = obtainWebView()
|
||||
if (baseUrl.isNotEmpty()) {
|
||||
suspendCoroutine { cont ->
|
||||
webView.webViewClient = ContinuationResumeWebViewClient(cont)
|
||||
webView.loadDataWithBaseURL(baseUrl, " ", "text/html", null, null)
|
||||
}
|
||||
}
|
||||
suspendCoroutine { cont ->
|
||||
webView.evaluateJavascript(script) { result ->
|
||||
cont.resume(result?.takeUnless { it == "null" })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
webViewExecutor.evaluateJs(baseUrl, script)
|
||||
}
|
||||
|
||||
override fun getDefaultUserAgent(): String = webViewUserAgent
|
||||
override fun getDefaultUserAgent(): String = webViewExecutor.defaultUserAgent ?: UserAgents.FIREFOX_MOBILE
|
||||
|
||||
override fun getConfig(source: MangaSource): MangaSourceConfig {
|
||||
return SourceSettings(androidContext, source)
|
||||
@@ -118,28 +87,4 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override fun createBitmap(width: Int, height: Int): Bitmap = BitmapWrapper.create(width, height)
|
||||
|
||||
@MainThread
|
||||
private fun obtainWebView(): WebView = webViewCached?.get() ?: WebView(androidContext).also {
|
||||
it.configureForParser(null)
|
||||
webViewCached = WeakReference(it)
|
||||
}
|
||||
|
||||
private fun obtainWebViewUserAgent(): String {
|
||||
val mainDispatcher = Dispatchers.Main.immediate
|
||||
return if (!mainDispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
|
||||
obtainWebViewUserAgentImpl()
|
||||
} else {
|
||||
runBlocking(mainDispatcher) {
|
||||
obtainWebViewUserAgentImpl()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun obtainWebViewUserAgentImpl() = runCatching {
|
||||
obtainWebView().settings.userAgentString.sanitizeHeaderValue()
|
||||
}.onFailure { e ->
|
||||
e.printStackTraceDebug()
|
||||
}.getOrDefault(UserAgents.FIREFOX_MOBILE)
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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,6 +7,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
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.parser.external.ExternalMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
||||
@@ -85,11 +86,16 @@ interface MangaRepository {
|
||||
|
||||
private fun createRepository(source: MangaSource): MangaRepository? = when (source) {
|
||||
is MangaParserSource -> ParserMangaRepository(
|
||||
parser = MangaParser(source, loaderContext),
|
||||
parser = loaderContext.newParserInstance(source),
|
||||
cache = contentCache,
|
||||
mirrorSwitcher = mirrorSwitcher,
|
||||
)
|
||||
|
||||
TestMangaSource -> TestMangaRepository(
|
||||
loaderContext = loaderContext,
|
||||
cache = contentCache,
|
||||
)
|
||||
|
||||
is ExternalMangaSource -> if (source.isAvailable(context)) {
|
||||
ExternalMangaRepository(
|
||||
contentResolver = context.contentResolver,
|
||||
|
||||
@@ -53,6 +53,9 @@ class ExternalPluginContentSource(
|
||||
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
|
||||
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
|
||||
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
|
||||
if (!filter.author.isNullOrEmpty()) {
|
||||
uri.appendQueryParameter("author", filter.author)
|
||||
}
|
||||
if (!filter.query.isNullOrEmpty()) {
|
||||
uri.appendQueryParameter("query", filter.query)
|
||||
}
|
||||
@@ -196,6 +199,7 @@ class ExternalPluginContentSource(
|
||||
isYearSupported = cursor.getBooleanOrDefault(COLUMN_YEAR, false),
|
||||
isYearRangeSupported = cursor.getBooleanOrDefault(COLUMN_YEAR_RANGE, false),
|
||||
isOriginalLocaleSupported = cursor.getBooleanOrDefault(COLUMN_ORIGINAL_LOCALE, false),
|
||||
isAuthorSearchSupported = cursor.getBooleanOrDefault(COLUMN_AUTHOR, false),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -10,15 +10,20 @@ import coil3.ColorImage
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.decode.DataSource
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.fetch.ImageFetchResult
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
import coil3.size.pxOrElse
|
||||
import coil3.toAndroidUri
|
||||
import coil3.toBitmap
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.FileSystem
|
||||
import okio.IOException
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
@@ -26,8 +31,16 @@ import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.MimeTypes
|
||||
import org.koitharu.kotatsu.core.util.ext.fetch
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
|
||||
import org.koitharu.kotatsu.local.data.FaviconCache
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageCache
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import coil3.Uri as CoilUri
|
||||
|
||||
@@ -36,6 +49,7 @@ class FaviconFetcher(
|
||||
private val options: Options,
|
||||
private val imageLoader: ImageLoader,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val localStorageCache: LocalStorageCache,
|
||||
) : Fetcher {
|
||||
|
||||
override suspend fun fetch(): FetchResult? {
|
||||
@@ -61,6 +75,16 @@ class FaviconFetcher(
|
||||
options.size.width.pxOrElse { FALLBACK_SIZE },
|
||||
options.size.height.pxOrElse { FALLBACK_SIZE },
|
||||
)
|
||||
val cacheKey = options.diskCacheKey ?: "${repository.source.name}_$sizePx"
|
||||
if (options.diskCachePolicy.readEnabled) {
|
||||
localStorageCache[cacheKey]?.let { file ->
|
||||
return SourceFetchResult(
|
||||
source = ImageSource(file.toOkioPath(), FileSystem.SYSTEM),
|
||||
mimeType = MimeTypes.probeMimeType(file)?.toString(),
|
||||
dataSource = DataSource.DISK,
|
||||
)
|
||||
}
|
||||
}
|
||||
var favicons = repository.getFavicons()
|
||||
var lastError: Exception? = null
|
||||
while (favicons.isNotEmpty()) {
|
||||
@@ -69,7 +93,11 @@ class FaviconFetcher(
|
||||
try {
|
||||
val result = imageLoader.fetch(icon.url, options)
|
||||
if (result != null) {
|
||||
return result
|
||||
return if (options.diskCachePolicy.writeEnabled) {
|
||||
writeToCache(cacheKey, result)
|
||||
} else {
|
||||
result
|
||||
}
|
||||
} else {
|
||||
favicons -= icon
|
||||
}
|
||||
@@ -97,8 +125,39 @@ class FaviconFetcher(
|
||||
)
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private suspend fun writeToCache(key: String, result: FetchResult): FetchResult = runCatchingCancellable {
|
||||
when (result) {
|
||||
is ImageFetchResult -> {
|
||||
if (result.dataSource == DataSource.NETWORK) {
|
||||
localStorageCache.set(key, result.image.toBitmap()).asFetchResult()
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
is SourceFetchResult -> {
|
||||
if (result.dataSource == DataSource.NETWORK) {
|
||||
result.source.source().use {
|
||||
localStorageCache.set(key, it, result.mimeType?.toMimeTypeOrNull()).asFetchResult()
|
||||
}
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrDefault(result)
|
||||
|
||||
private fun File.asFetchResult() = SourceFetchResult(
|
||||
source = ImageSource(toOkioPath(), FileSystem.SYSTEM),
|
||||
mimeType = MimeTypes.probeMimeType(this)?.toString(),
|
||||
dataSource = DataSource.DISK,
|
||||
)
|
||||
|
||||
class Factory @Inject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
@FaviconCache private val faviconCache: LocalStorageCache,
|
||||
) : Fetcher.Factory<CoilUri> {
|
||||
|
||||
override fun create(
|
||||
@@ -106,7 +165,7 @@ class FaviconFetcher(
|
||||
options: Options,
|
||||
imageLoader: ImageLoader
|
||||
): Fetcher? = if (data.scheme == URI_SCHEME_FAVICON) {
|
||||
FaviconFetcher(data.toAndroidUri(), options, imageLoader, mangaRepositoryFactory)
|
||||
FaviconFetcher(data.toAndroidUri(), options, imageLoader, mangaRepositoryFactory, faviconCache)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -15,12 +15,17 @@ import androidx.core.os.LocaleListCompat
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.preference.PreferenceManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.core.network.DoHProvider
|
||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||
import org.koitharu.kotatsu.core.util.ext.getEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeChanges
|
||||
import org.koitharu.kotatsu.core.util.ext.putAll
|
||||
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
|
||||
@@ -82,6 +87,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isNavBarPinned: Boolean
|
||||
get() = prefs.getBoolean(KEY_NAV_PINNED, false)
|
||||
|
||||
val isMainFabEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_MAIN_FAB, true)
|
||||
|
||||
var gridSize: Int
|
||||
get() = prefs.getInt(KEY_GRID_SIZE, 100)
|
||||
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
|
||||
@@ -480,6 +488,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getBoolean(KEY_WEBTOON_GAPS, false)
|
||||
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)
|
||||
val defaultWebtoonZoomOut: Float
|
||||
get() = prefs.getInt(KEY_WEBTOON_ZOOM_OUT, 0).coerceIn(0, 50) / 100f
|
||||
@@ -494,6 +506,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
var isReaderAutoscrollFabVisible: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_AUTOSCROLL_FAB, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_READER_AUTOSCROLL_FAB, value) }
|
||||
|
||||
val isPagesPreloadEnabled: Boolean
|
||||
get() {
|
||||
if (isBackgroundNetworkRestricted()) {
|
||||
@@ -509,6 +525,16 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val is32BitColorsEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_32BIT_COLOR, false)
|
||||
|
||||
val isDiscordRpcEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_DISCORD_RPC, false)
|
||||
|
||||
val isDiscordRpcSkipNsfw: Boolean
|
||||
get() = prefs.getBoolean(KEY_DISCORD_RPC_SKIP_NSFW, false)
|
||||
|
||||
var discordToken: String?
|
||||
get() = prefs.getString(KEY_DISCORD_TOKEN, null)?.trim()?.nullIfEmpty()
|
||||
set(value) = prefs.edit { putString(KEY_DISCORD_TOKEN, value?.nullIfEmpty()) }
|
||||
|
||||
val isPeriodicalBackupEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false)
|
||||
|
||||
@@ -598,7 +624,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
prefs.unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
fun observe() = prefs.observe()
|
||||
fun observeChanges() = prefs.observeChanges()
|
||||
|
||||
fun observe(vararg keys: String): Flow<String?> = prefs.observeChanges()
|
||||
.filter { key -> key == null || key in keys }
|
||||
.onStart { emit(null) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
fun getAllValues(): Map<String, *> = prefs.all
|
||||
|
||||
@@ -721,6 +752,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_WEBTOON_GAPS = "webtoon_gaps"
|
||||
const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
|
||||
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_APP_LOCALE = "app_locale"
|
||||
const val KEY_SOURCES_GRID = "sources_grid"
|
||||
@@ -728,6 +760,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_TIPS_CLOSED = "tips_closed"
|
||||
const val KEY_SSL_BYPASS = "ssl_bypass"
|
||||
const val KEY_READER_AUTOSCROLL_SPEED = "as_speed"
|
||||
const val KEY_READER_AUTOSCROLL_FAB = "as_fab"
|
||||
const val KEY_MIRROR_SWITCHING = "mirror_switching"
|
||||
const val KEY_PROXY = "proxy"
|
||||
const val KEY_PROXY_TYPE = "proxy_type_2"
|
||||
@@ -743,6 +776,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_NAV_MAIN = "nav_main"
|
||||
const val KEY_NAV_LABELS = "nav_labels"
|
||||
const val KEY_NAV_PINNED = "nav_pinned"
|
||||
const val KEY_MAIN_FAB = "main_fab"
|
||||
const val KEY_32BIT_COLOR = "enhanced_colors"
|
||||
const val KEY_SOURCES_ORDER = "sources_sort_order"
|
||||
const val KEY_SOURCES_CATALOG = "sources_catalog"
|
||||
@@ -768,6 +802,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_BACKUP_TG_CHAT = "backup_periodic_tg_chat_id"
|
||||
const val KEY_MANGA_LIST_BADGES = "manga_list_badges"
|
||||
const val KEY_TAGS_WARNINGS = "tags_warnings"
|
||||
const val KEY_DISCORD_RPC = "discord_rpc"
|
||||
const val KEY_DISCORD_RPC_SKIP_NSFW = "discord_rpc_skip_nsfw"
|
||||
const val KEY_DISCORD_TOKEN = "discord_token"
|
||||
|
||||
// keys for non-persistent preferences
|
||||
const val KEY_APP_VERSION = "app_version"
|
||||
@@ -780,6 +817,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_PROXY_TEST = "proxy_test"
|
||||
const val KEY_OPEN_BROWSER = "open_browser"
|
||||
const val KEY_HANDLE_LINKS = "handle_links"
|
||||
const val KEY_BACKUP_TG = "backup_periodic_tg"
|
||||
const val KEY_BACKUP_TG_OPEN = "backup_periodic_tg_open"
|
||||
const val KEY_BACKUP_TG_TEST = "backup_periodic_tg_test"
|
||||
const val KEY_CLEAR_MANGA_DATA = "manga_data_clear"
|
||||
|
||||
@@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.transform
|
||||
fun <T> AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow {
|
||||
var lastValue: T = valueProducer()
|
||||
emit(lastValue)
|
||||
observe().collect {
|
||||
observeChanges().collect {
|
||||
if (it == key) {
|
||||
val value = valueProducer()
|
||||
if (value != lastValue) {
|
||||
@@ -25,7 +25,7 @@ fun <T> AppSettings.observeAsStateFlow(
|
||||
scope: CoroutineScope,
|
||||
key: String,
|
||||
valueProducer: AppSettings.() -> T,
|
||||
): StateFlow<T> = observe().transform {
|
||||
): StateFlow<T> = observeChanges().transform {
|
||||
if (it == key) {
|
||||
emit(valueProducer())
|
||||
}
|
||||
|
||||
@@ -66,8 +66,9 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
||||
|
||||
companion object {
|
||||
|
||||
const val KEY_SORT_ORDER = "sort_order"
|
||||
const val KEY_SLOWDOWN = "slowdown"
|
||||
const val KEY_DOMAIN = "domain"
|
||||
const val KEY_NO_CAPTCHA = "no_captcha"
|
||||
const val KEY_SLOWDOWN = "slowdown"
|
||||
const val KEY_SORT_ORDER = "sort_order"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
@@ -86,6 +88,12 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
(activity as? SettingsActivity)?.setSectionTitle(title)
|
||||
}
|
||||
|
||||
protected fun getWarningIcon(): Drawable? = context?.let { ctx ->
|
||||
ContextCompat.getDrawable(ctx, R.drawable.ic_alert_outline)?.also {
|
||||
it.setTint(ContextCompat.getColor(ctx, R.color.warning))
|
||||
}
|
||||
}
|
||||
|
||||
private fun focusPreference(key: String) {
|
||||
val pref = findPreference<Preference>(key)
|
||||
if (pref == null) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.util.ext.EventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import kotlin.coroutines.AbstractCoroutineContextElement
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
|
||||
@@ -43,13 +44,13 @@ abstract class BaseViewModel : ViewModel() {
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
block: suspend CoroutineScope.() -> Unit
|
||||
): Job = viewModelScope.launch(context + createErrorHandler(), start, block)
|
||||
): Job = viewModelScope.launch(context.withDefaultExceptionHandler(), start, block)
|
||||
|
||||
protected fun launchLoadingJob(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
block: suspend CoroutineScope.() -> Unit
|
||||
): Job = viewModelScope.launch(context + createErrorHandler(), start) {
|
||||
): Job = viewModelScope.launch(context.withDefaultExceptionHandler(), start) {
|
||||
loadingCounter.increment()
|
||||
try {
|
||||
block()
|
||||
@@ -80,10 +81,28 @@ abstract class BaseViewModel : ViewModel() {
|
||||
|
||||
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
|
||||
|
||||
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
|
||||
throwable.printStackTraceDebug()
|
||||
if (throwable !is CancellationException) {
|
||||
errorEvent.call(throwable)
|
||||
private fun CoroutineContext.withDefaultExceptionHandler() =
|
||||
if (this[CoroutineExceptionHandler.Key] is EventExceptionHandler) {
|
||||
this
|
||||
} else {
|
||||
this + EventExceptionHandler(errorEvent)
|
||||
}
|
||||
|
||||
protected object SkipErrors : AbstractCoroutineContextElement(Key) {
|
||||
|
||||
private object Key : CoroutineContext.Key<SkipErrors>
|
||||
}
|
||||
|
||||
protected class EventExceptionHandler(
|
||||
private val event: MutableEventFlow<Throwable>,
|
||||
) : AbstractCoroutineContextElement(CoroutineExceptionHandler),
|
||||
CoroutineExceptionHandler {
|
||||
|
||||
override fun handleException(context: CoroutineContext, exception: Throwable) {
|
||||
exception.printStackTraceDebug()
|
||||
if (context[SkipErrors.key] == null && exception !is CancellationException) {
|
||||
event.call(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import android.view.View
|
||||
|
||||
fun interface OnContextClickListenerCompat {
|
||||
|
||||
fun onContextClick(v: View): Boolean
|
||||
}
|
||||
@@ -10,7 +10,7 @@ class RememberCheckListener(
|
||||
var isChecked: Boolean = initialValue
|
||||
private set
|
||||
|
||||
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
|
||||
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
|
||||
this.isChecked = isChecked
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import coil3.asImage
|
||||
import coil3.request.Disposable
|
||||
import coil3.request.ImageRequest
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler.Companion.ignoreCaptchaErrors
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler.Companion.suppressCaptchaErrors
|
||||
import org.koitharu.kotatsu.core.image.CoilImageView
|
||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
@@ -57,7 +57,7 @@ class FaviconView @JvmOverloads constructor(
|
||||
.fallback(fallbackFactory)
|
||||
.placeholder(placeholderFactory)
|
||||
.mangaSourceExtra(mangaSource)
|
||||
.ignoreCaptchaErrors()
|
||||
.suppressCaptchaErrors()
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,17 +2,16 @@ package org.koitharu.kotatsu.core.ui.list
|
||||
|
||||
import android.view.View
|
||||
import android.view.View.OnClickListener
|
||||
import android.view.View.OnContextClickListener
|
||||
import android.view.View.OnLongClickListener
|
||||
import androidx.core.util.Function
|
||||
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>(
|
||||
private val adapterDelegate: AdapterDelegateViewBindingViewHolder<out I, *>,
|
||||
private val clickListener: OnListItemClickListener<O>,
|
||||
private val itemMapper: Function<I, O>,
|
||||
) : OnClickListener, OnLongClickListener, OnContextClickListenerCompat {
|
||||
) : OnClickListener, OnLongClickListener, OnContextClickListener {
|
||||
|
||||
override fun onClick(v: View) {
|
||||
clickListener.onItemClick(mappedItem(), v)
|
||||
@@ -33,7 +32,7 @@ class AdapterDelegateClickListenerAdapter<I, O>(
|
||||
fun attach(itemView: View) {
|
||||
itemView.setOnClickListener(this)
|
||||
itemView.setOnLongClickListener(this)
|
||||
itemView.setOnContextClickListenerCompat(this)
|
||||
itemView.setOnContextClickListener(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -186,6 +186,7 @@ class ListSelectionController(
|
||||
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
if (event == Lifecycle.Event.ON_CREATE) {
|
||||
source.lifecycle.removeObserver(this)
|
||||
val registry = registryOwner.savedStateRegistry
|
||||
registry.registerSavedStateProvider(PROVIDER_NAME, this@ListSelectionController)
|
||||
val state = registry.consumeRestoredStateForKey(PROVIDER_NAME)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.ui.util
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.view.ViewGroup
|
||||
import android.view.Window
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
@@ -14,7 +13,6 @@ import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -37,14 +35,10 @@ class ActionModeDelegate : OnBackPressedCallback(false) {
|
||||
listeners?.forEach { it.onActionModeStarted(mode) }
|
||||
if (window != null) {
|
||||
val ctx = window.context
|
||||
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
ColorUtils.compositeColors(
|
||||
ContextCompat.getColor(ctx, materialR.color.m3_appbar_overlay_color),
|
||||
ctx.getThemeColor(materialR.attr.colorSurface),
|
||||
)
|
||||
} else {
|
||||
ContextCompat.getColor(ctx, R.color.kotatsu_surface)
|
||||
}
|
||||
val actionModeColor = ColorUtils.compositeColors(
|
||||
ContextCompat.getColor(ctx, materialR.color.m3_appbar_overlay_color),
|
||||
ctx.getThemeColor(materialR.attr.colorSurface),
|
||||
)
|
||||
defaultStatusBarColor = window.statusBarColor
|
||||
window.statusBarColor = actionModeColor
|
||||
val insets = ViewCompat.getRootWindowInsets(window.decorView)
|
||||
|
||||
@@ -4,12 +4,10 @@ import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.view.MenuProvider
|
||||
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
|
||||
|
||||
class PopupMenuMediator(
|
||||
private val provider: MenuProvider,
|
||||
) : View.OnLongClickListener, OnContextClickListenerCompat, PopupMenu.OnMenuItemClickListener,
|
||||
) : View.OnLongClickListener, View.OnContextClickListener, PopupMenu.OnMenuItemClickListener,
|
||||
PopupMenu.OnDismissListener {
|
||||
|
||||
override fun onContextClick(v: View): Boolean = onLongClick(v)
|
||||
@@ -37,6 +35,6 @@ class PopupMenuMediator(
|
||||
|
||||
fun attach(view: View) {
|
||||
view.setOnLongClickListener(this)
|
||||
view.setOnContextClickListenerCompat(this)
|
||||
view.setOnContextClickListener(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ open class StackLayout @JvmOverloads constructor(
|
||||
val h = b - t - paddingTop - paddingBottom
|
||||
visibleChildren.clear()
|
||||
children.filterNotTo(visibleChildren) { it.isGone }
|
||||
if (w <= 0 || h <= 0 || visibleChildren.isEmpty) {
|
||||
if (w <= 0 || h <= 0 || visibleChildren.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val xStep = w / (visibleChildren.size + 1)
|
||||
|
||||
@@ -1,61 +1,38 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
import androidx.collection.ArrayMap
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
|
||||
open class MultiMutex<T : Any> : Set<T> {
|
||||
open class MultiMutex<T : Any> {
|
||||
|
||||
private val delegates = ArrayMap<T, Mutex>()
|
||||
private val delegates = ConcurrentHashMap<T, Mutex>()
|
||||
|
||||
override val size: Int
|
||||
get() = delegates.size
|
||||
@VisibleForTesting
|
||||
val size: Int
|
||||
get() = delegates.count { it.value.isLocked }
|
||||
|
||||
override fun contains(element: T): Boolean = synchronized(delegates) {
|
||||
delegates.containsKey(element)
|
||||
}
|
||||
fun isNotEmpty() = delegates.any { it.value.isLocked }
|
||||
|
||||
override fun containsAll(elements: Collection<T>): Boolean = synchronized(delegates) {
|
||||
elements.all { x -> delegates.containsKey(x) }
|
||||
}
|
||||
|
||||
override fun isEmpty(): Boolean = delegates.isEmpty()
|
||||
|
||||
override fun iterator(): Iterator<T> = synchronized(delegates) {
|
||||
delegates.keys.toList()
|
||||
}.iterator()
|
||||
|
||||
fun isLocked(element: T): Boolean = synchronized(delegates) {
|
||||
delegates[element]?.isLocked == true
|
||||
}
|
||||
|
||||
fun tryLock(element: T): Boolean {
|
||||
val mutex = synchronized(delegates) {
|
||||
delegates.getOrPut(element, ::Mutex)
|
||||
}
|
||||
return mutex.tryLock()
|
||||
}
|
||||
fun isEmpty() = delegates.none { it.value.isLocked }
|
||||
|
||||
suspend fun lock(element: T) {
|
||||
val mutex = synchronized(delegates) {
|
||||
delegates.getOrPut(element, ::Mutex)
|
||||
}
|
||||
val mutex = delegates.computeIfAbsent(element) { Mutex() }
|
||||
mutex.lock()
|
||||
}
|
||||
|
||||
fun unlock(element: T) {
|
||||
synchronized(delegates) {
|
||||
delegates.remove(element)?.unlock()
|
||||
}
|
||||
delegates[element]?.unlock()
|
||||
}
|
||||
|
||||
suspend inline fun <R> withLock(element: T, block: () -> R): R {
|
||||
contract {
|
||||
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
||||
}
|
||||
lock(element)
|
||||
return try {
|
||||
lock(element)
|
||||
block()
|
||||
} finally {
|
||||
unlock(element)
|
||||
|
||||
@@ -134,6 +134,28 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T1, T2, T3, T4, T5, T6, T7, R> combine(
|
||||
flow: Flow<T1>,
|
||||
flow2: Flow<T2>,
|
||||
flow3: Flow<T3>,
|
||||
flow4: Flow<T4>,
|
||||
flow5: Flow<T5>,
|
||||
flow6: Flow<T6>,
|
||||
flow7: Flow<T7>,
|
||||
transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R,
|
||||
): Flow<R> = combine(flow, flow2, flow3, flow4, flow5, flow6, flow7) { args: Array<*> ->
|
||||
transform(
|
||||
args[0] as T1,
|
||||
args[1] as T2,
|
||||
args[2] as T3,
|
||||
args[3] as T4,
|
||||
args[4] as T5,
|
||||
args[5] as T6,
|
||||
args[6] as T7,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun <T : Any> Flow<T?>.firstNotNull(): T = checkNotNull(first { x -> x != null })
|
||||
|
||||
suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x != null }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import androidx.annotation.CheckResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
@@ -12,6 +15,7 @@ import okio.FileSystem
|
||||
import okio.IOException
|
||||
import okio.Path
|
||||
import okio.Source
|
||||
import okio.source
|
||||
import org.koitharu.kotatsu.core.util.CancellableSource
|
||||
import org.koitharu.kotatsu.core.util.progress.ProgressResponseBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
@@ -57,3 +61,8 @@ fun FileSystem.isRegularFile(path: Path) = try {
|
||||
} catch (_: IOException) {
|
||||
false
|
||||
}
|
||||
|
||||
@CheckResult
|
||||
fun ContentResolver.openSource(uri: Uri): Source = checkNotNull(openInputStream(uri)) {
|
||||
"Cannot open input stream from $uri"
|
||||
}.source()
|
||||
|
||||
@@ -37,7 +37,7 @@ fun <E : Enum<E>> SharedPreferences.Editor.putEnumValue(key: String, value: E?)
|
||||
putString(key, value?.name)
|
||||
}
|
||||
|
||||
fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
|
||||
fun SharedPreferences.observeChanges(): Flow<String?> = callbackFlow {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
trySendBlocking(key)
|
||||
}
|
||||
@@ -49,7 +49,7 @@ fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
|
||||
|
||||
fun <T> SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow<T> = flow {
|
||||
emit(valueProducer())
|
||||
observe().collect { upstreamKey ->
|
||||
observeChanges().collect { upstreamKey ->
|
||||
if (upstreamKey == key) {
|
||||
emit(valueProducer())
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.database.sqlite.SQLiteFullException
|
||||
import androidx.annotation.DrawableRes
|
||||
import coil3.network.HttpException
|
||||
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.http2.StreamResetException
|
||||
import okio.FileNotFoundException
|
||||
@@ -52,6 +53,7 @@ import java.net.SocketException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.util.Locale
|
||||
import java.util.zip.ZipException
|
||||
|
||||
private const val MSG_NO_SPACE_LEFT = "No space left on device"
|
||||
private const val MSG_CONNECTION_RESET = "Connection reset"
|
||||
@@ -63,6 +65,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessag
|
||||
?: resources.getString(R.string.error_occurred)
|
||||
|
||||
private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) {
|
||||
is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message
|
||||
is CaughtException -> cause.getDisplayMessageOrNull(resources)
|
||||
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
|
||||
is ScrobblerAuthRequiredException -> resources.getString(
|
||||
@@ -92,6 +95,7 @@ private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = w
|
||||
}
|
||||
}
|
||||
|
||||
is ZipException -> resources.getString(R.string.error_corrupted_zip, this.message.orEmpty())
|
||||
is SQLiteFullException -> resources.getString(R.string.error_no_space_left)
|
||||
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
||||
@@ -168,8 +172,9 @@ fun Throwable.getCauseUrl(): String? = when (this) {
|
||||
}
|
||||
|
||||
private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) {
|
||||
404 -> resources.getString(R.string.not_found_404)
|
||||
403 -> resources.getString(R.string.access_denied_403)
|
||||
HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404)
|
||||
HttpURLConnection.HTTP_FORBIDDEN -> resources.getString(R.string.access_denied_403)
|
||||
HttpURLConnection.HTTP_GATEWAY_TIMEOUT -> resources.getString(R.string.network_unavailable)
|
||||
in 500..599 -> resources.getString(R.string.server_error, statusCode)
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import com.google.android.material.progressindicator.BaseProgressIndicator
|
||||
import com.google.android.material.slider.RangeSlider
|
||||
import com.google.android.material.slider.Slider
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
|
||||
@@ -169,12 +168,16 @@ 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?) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
tooltipText = tooltip
|
||||
} else if (!isLongClickable) { // don't use TooltipCompat if has a LongClickListener
|
||||
TooltipCompat.setTooltipText(this, tooltip)
|
||||
}
|
||||
}
|
||||
|
||||
fun View.setTooltipCompat(@StringRes tooltipResId: Int) = setTooltipCompat(context.getString(tooltipResId))
|
||||
|
||||
val Toolbar.menuView: ActionMenuView?
|
||||
get() {
|
||||
menu // to call ensureMenu()
|
||||
@@ -201,7 +204,7 @@ fun Chip.setProgressIcon() {
|
||||
fun View.setContentDescriptionAndTooltip(@StringRes resId: Int) {
|
||||
val text = resources.getString(resId)
|
||||
contentDescription = text
|
||||
TooltipCompat.setTooltipText(this, text)
|
||||
setTooltipCompat(text)
|
||||
}
|
||||
|
||||
fun View.getWindowBounds(): Rect {
|
||||
|
||||
@@ -31,13 +31,12 @@ data class MangaDetails(
|
||||
val id: Long
|
||||
get() = manga.id
|
||||
|
||||
val chapters: Map<String?, List<MangaChapter>> = manga.chapters?.groupBy { it.branch }.orEmpty()
|
||||
|
||||
val branches: Set<String?>
|
||||
get() = chapters.keys
|
||||
|
||||
val allChapters: List<MangaChapter> by lazy { mergeChapters() }
|
||||
|
||||
val chapters: Map<String?, List<MangaChapter>> by lazy {
|
||||
allChapters.groupBy { it.branch }
|
||||
}
|
||||
|
||||
val isLocal
|
||||
get() = manga.isLocal
|
||||
|
||||
@@ -51,7 +50,22 @@ data class MangaDetails(
|
||||
.ifNullOrEmpty { localManga?.manga?.coverUrl }
|
||||
?.nullIfEmpty()
|
||||
|
||||
fun toManga() = manga.withOverride(override)
|
||||
private val mergedManga by lazy {
|
||||
if (localManga == null) {
|
||||
// fast path
|
||||
manga.withOverride(override)
|
||||
} else {
|
||||
manga.copy(
|
||||
title = override?.title.ifNullOrEmpty { manga.title },
|
||||
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
|
||||
largeCoverUrl = override?.coverUrl.ifNullOrEmpty { manga.largeCoverUrl },
|
||||
contentRating = override?.contentRating ?: manga.contentRating,
|
||||
chapters = allChapters,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun toManga() = mergedManga
|
||||
|
||||
fun getLocale(): Locale? {
|
||||
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package org.koitharu.kotatsu.details.domain
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
@@ -9,30 +9,31 @@ import androidx.core.text.parseAsHtml
|
||||
import coil3.request.CachePolicy
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.IOException
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.nav.MangaIntent
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.peek
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.ui.model.MangaOverride
|
||||
import org.koitharu.kotatsu.core.util.ext.sanitize
|
||||
import org.koitharu.kotatsu.details.data.MangaDetails
|
||||
import org.koitharu.kotatsu.explore.domain.RecoverMangaUseCase
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.recoverNotNull
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.tracker.domain.CheckNewChaptersUseCase
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
class DetailsLoadUseCase @Inject constructor(
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
@@ -40,84 +41,116 @@ class DetailsLoadUseCase @Inject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val recoverUseCase: RecoverMangaUseCase,
|
||||
private val imageGetter: Html.ImageGetter,
|
||||
private val newChaptersUseCaseProvider: Provider<CheckNewChaptersUseCase>,
|
||||
private val networkState: NetworkState,
|
||||
) {
|
||||
|
||||
operator fun invoke(intent: MangaIntent, force: Boolean): Flow<MangaDetails> = channelFlow {
|
||||
operator fun invoke(intent: MangaIntent, force: Boolean): Flow<MangaDetails> = flow {
|
||||
val manga = requireNotNull(mangaDataRepository.resolveIntent(intent, withChapters = true)) {
|
||||
"Cannot resolve intent $intent"
|
||||
}
|
||||
val override = mangaDataRepository.getOverride(manga.id)
|
||||
send(
|
||||
emit(
|
||||
MangaDetails(
|
||||
manga = manga,
|
||||
localManga = null,
|
||||
override = override,
|
||||
description = null,
|
||||
description = manga.description?.parseAsHtml(withImages = false),
|
||||
isLoaded = false,
|
||||
),
|
||||
)
|
||||
val local = if (!manga.isLocal) {
|
||||
async {
|
||||
localMangaRepository.findSavedManga(manga)
|
||||
}
|
||||
if (manga.isLocal) {
|
||||
loadLocal(manga, override, force)
|
||||
} else {
|
||||
null
|
||||
loadRemote(manga, override, force)
|
||||
}
|
||||
if (!force && networkState.isOfflineOrRestricted()) {
|
||||
// try to avoid loading if has saved manga
|
||||
val localManga = local?.await()
|
||||
if (manga.isLocal || localManga != null) {
|
||||
send(
|
||||
MangaDetails(
|
||||
manga = manga,
|
||||
localManga = localManga,
|
||||
override = override,
|
||||
description = manga.description?.parseAsHtml(withImages = true)?.trim(),
|
||||
isLoaded = true,
|
||||
),
|
||||
)
|
||||
return@channelFlow
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
.flowOn(Dispatchers.Default)
|
||||
|
||||
/**
|
||||
* Load local manga + try to load the linked remote one if network is not restricted
|
||||
* Suppress any network errors
|
||||
*/
|
||||
private suspend fun FlowCollector<MangaDetails>.loadLocal(manga: Manga, override: MangaOverride?, force: Boolean) {
|
||||
val skipNetworkLoad = !force && networkState.isOfflineOrRestricted()
|
||||
val localDetails = localMangaRepository.getDetails(manga)
|
||||
emit(
|
||||
MangaDetails(
|
||||
manga = localDetails,
|
||||
localManga = null,
|
||||
override = override,
|
||||
description = localDetails.description?.parseAsHtml(withImages = false),
|
||||
isLoaded = skipNetworkLoad,
|
||||
),
|
||||
)
|
||||
if (skipNetworkLoad) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
val details = getDetails(manga, force)
|
||||
launch { mangaDataRepository.updateChapters(details) }
|
||||
launch { updateTracker(details) }
|
||||
send(
|
||||
val remoteManga = localMangaRepository.getRemoteManga(manga)
|
||||
if (remoteManga == null) {
|
||||
emit(
|
||||
MangaDetails(
|
||||
manga = details,
|
||||
localManga = local?.peek(),
|
||||
manga = localDetails,
|
||||
localManga = null,
|
||||
override = override,
|
||||
description = details.description?.parseAsHtml(withImages = false)?.trim(),
|
||||
isLoaded = false,
|
||||
),
|
||||
)
|
||||
send(
|
||||
MangaDetails(
|
||||
manga = details,
|
||||
localManga = local?.await(),
|
||||
override = override,
|
||||
description = details.description?.parseAsHtml(withImages = true)?.trim(),
|
||||
description = localDetails.description?.parseAsHtml(withImages = true),
|
||||
isLoaded = true,
|
||||
),
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
local?.await()?.manga?.also { localManga ->
|
||||
send(
|
||||
MangaDetails(
|
||||
manga = localManga,
|
||||
localManga = null,
|
||||
override = override,
|
||||
description = localManga.description?.parseAsHtml(withImages = false)?.trim(),
|
||||
isLoaded = true,
|
||||
),
|
||||
)
|
||||
} ?: close(e)
|
||||
} else {
|
||||
val remoteDetails = getDetails(remoteManga, force).getOrNull()
|
||||
emit(
|
||||
MangaDetails(
|
||||
manga = remoteDetails ?: remoteManga,
|
||||
localManga = LocalManga(localDetails),
|
||||
override = override,
|
||||
description = (remoteDetails ?: localDetails).description?.parseAsHtml(withImages = true),
|
||||
isLoaded = true,
|
||||
),
|
||||
)
|
||||
if (remoteDetails != null) {
|
||||
mangaDataRepository.updateChapters(remoteDetails)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load remote manga + saved one if available
|
||||
* Throw network errors after loading local manga only
|
||||
*/
|
||||
private suspend fun FlowCollector<MangaDetails>.loadRemote(
|
||||
manga: Manga,
|
||||
override: MangaOverride?,
|
||||
force: Boolean
|
||||
) = coroutineScope {
|
||||
val remoteDeferred = async {
|
||||
getDetails(manga, force)
|
||||
}
|
||||
val localManga = localMangaRepository.findSavedManga(manga, withDetails = true)
|
||||
if (localManga != null) {
|
||||
emit(
|
||||
MangaDetails(
|
||||
manga = manga,
|
||||
localManga = localManga,
|
||||
override = override,
|
||||
description = localManga.manga.description?.parseAsHtml(withImages = true),
|
||||
isLoaded = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
val remoteDetails = remoteDeferred.await().getOrThrow()
|
||||
emit(
|
||||
MangaDetails(
|
||||
manga = remoteDetails,
|
||||
localManga = localManga,
|
||||
override = override,
|
||||
description = (remoteDetails.description
|
||||
?: localManga?.manga?.description)?.parseAsHtml(withImages = true),
|
||||
isLoaded = true,
|
||||
),
|
||||
)
|
||||
mangaDataRepository.updateChapters(remoteDetails)
|
||||
}
|
||||
|
||||
private suspend fun getDetails(seed: Manga, force: Boolean) = runCatchingCancellable {
|
||||
val repository = mangaRepositoryFactory.create(seed.source)
|
||||
if (repository is CachingMangaRepository) {
|
||||
@@ -131,20 +164,18 @@ class DetailsLoadUseCase @Inject constructor(
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.getOrThrow()
|
||||
|
||||
private suspend fun String.parseAsHtml(withImages: Boolean): CharSequence? {
|
||||
return if (withImages) {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
parseAsHtml(imageGetter = imageGetter)
|
||||
}.filterSpans()
|
||||
} else {
|
||||
runInterruptible(Dispatchers.Default) {
|
||||
parseAsHtml()
|
||||
}.filterSpans().sanitize()
|
||||
}.takeUnless { it.isBlank() }
|
||||
}
|
||||
|
||||
private suspend fun String.parseAsHtml(withImages: Boolean): CharSequence? = if (withImages) {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
parseAsHtml(imageGetter = imageGetter)
|
||||
}.filterSpans()
|
||||
} else {
|
||||
runInterruptible(Dispatchers.Default) {
|
||||
parseAsHtml()
|
||||
}.filterSpans().sanitize()
|
||||
}.trim().nullIfEmpty()
|
||||
|
||||
private fun Spanned.filterSpans(): Spanned {
|
||||
val spannable = SpannableString.valueOf(this)
|
||||
val spans = spannable.getSpans<ForegroundColorSpan>()
|
||||
@@ -153,10 +184,4 @@ class DetailsLoadUseCase @Inject constructor(
|
||||
}
|
||||
return spannable
|
||||
}
|
||||
|
||||
private suspend fun updateTracker(details: Manga) = runCatchingCancellable {
|
||||
newChaptersUseCaseProvider.get()(details)
|
||||
}.onFailure { e ->
|
||||
e.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ fun MangaDetails.mapChapters(
|
||||
branch: String?,
|
||||
bookmarks: List<Bookmark>,
|
||||
isGrid: Boolean,
|
||||
isDownloadedOnly: Boolean,
|
||||
): List<ChapterListItem> {
|
||||
val remoteChapters = chapters[branch].orEmpty()
|
||||
val localChapters = local?.manga?.getChapters(branch).orEmpty()
|
||||
@@ -35,19 +36,21 @@ fun MangaDetails.mapChapters(
|
||||
null
|
||||
}
|
||||
var isUnread = currentChapterId !in ids
|
||||
for (chapter in remoteChapters) {
|
||||
val local = localMap?.remove(chapter.id)
|
||||
if (chapter.id == currentChapterId) {
|
||||
isUnread = true
|
||||
if (!isDownloadedOnly || local?.manga?.chapters == null) {
|
||||
for (chapter in remoteChapters) {
|
||||
val local = localMap?.remove(chapter.id)
|
||||
if (chapter.id == currentChapterId) {
|
||||
isUnread = true
|
||||
}
|
||||
result += (local ?: chapter).toListItem(
|
||||
isCurrent = chapter.id == currentChapterId,
|
||||
isUnread = isUnread,
|
||||
isNew = isUnread && result.size >= newFrom,
|
||||
isDownloaded = local != null,
|
||||
isBookmarked = chapter.id in bookmarked,
|
||||
isGrid = isGrid,
|
||||
)
|
||||
}
|
||||
result += (local ?: chapter).toListItem(
|
||||
isCurrent = chapter.id == currentChapterId,
|
||||
isUnread = isUnread,
|
||||
isNew = isUnread && result.size >= newFrom,
|
||||
isDownloaded = local != null,
|
||||
isBookmarked = chapter.id in bookmarked,
|
||||
isGrid = isGrid,
|
||||
)
|
||||
}
|
||||
if (!localMap.isNullOrEmpty()) {
|
||||
for (chapter in localMap.values) {
|
||||
|
||||
@@ -11,7 +11,6 @@ import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.widget.TooltipCompat
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
import androidx.core.text.method.LinkMovementMethodCompat
|
||||
@@ -80,6 +79,7 @@ import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.parentView
|
||||
import org.koitharu.kotatsu.core.util.ext.setTooltipCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.start
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||
@@ -209,9 +209,7 @@ class DetailsActivity :
|
||||
|
||||
override fun onProvideAssistContent(outContent: AssistContent) {
|
||||
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 }
|
||||
@@ -260,7 +258,7 @@ class DetailsActivity :
|
||||
R.id.button_scrobbling_more -> {
|
||||
router.showScrobblingSelectorSheet(
|
||||
manga = viewModel.getMangaOrNull() ?: return,
|
||||
scrobblerService = viewModel.scrobblingInfo.value.firstOrNull()?.scrobbler
|
||||
scrobblerService = viewModel.scrobblingInfo.value.firstOrNull()?.scrobbler,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -389,7 +387,7 @@ class DetailsActivity :
|
||||
mangaGridItemAD(
|
||||
sizeResolver = StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)),
|
||||
) { item, view ->
|
||||
router.openDetails(item)
|
||||
router.openDetails(item.toMangaWithOverride())
|
||||
},
|
||||
).also { rv.adapter = it }
|
||||
adapter.items = related
|
||||
@@ -455,7 +453,7 @@ class DetailsActivity :
|
||||
textViewSourceLabel.isVisible = false
|
||||
} else {
|
||||
textViewSource.textAndVisible = manga.source.getTitle(this@DetailsActivity)
|
||||
TooltipCompat.setTooltipText(textViewSource, manga.source.getSummary(this@DetailsActivity))
|
||||
textViewSource.setTooltipCompat(manga.source.getSummary(this@DetailsActivity))
|
||||
textViewSourceLabel.isVisible = textViewSource.isVisible == true
|
||||
}
|
||||
val faviconPlaceholderFactory = FaviconDrawable.Factory(R.style.FaviconDrawable_Chip)
|
||||
|
||||
@@ -182,7 +182,7 @@ class DetailsViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
loadingJob = doLoad(force = false)
|
||||
launchJob(Dispatchers.Default) {
|
||||
launchJob(Dispatchers.Default + SkipErrors) {
|
||||
val manga = mangaDetails.firstOrNull { !it?.chapters.isNullOrEmpty() } ?: return@launchJob
|
||||
val h = history.firstOrNull()
|
||||
if (h != null) {
|
||||
|
||||
@@ -106,7 +106,7 @@ class ReadButtonDelegate(
|
||||
}
|
||||
|
||||
private fun openReader(isIncognitoMode: Boolean) {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
val manga = viewModel.getMangaOrNull() ?: return
|
||||
if (viewModel.historyInfo.value.isChapterMissing) {
|
||||
Snackbar.make(buttonRead, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT)
|
||||
.show() // TODO
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package org.koitharu.kotatsu.details.ui.adapter
|
||||
|
||||
import android.graphics.Typeface
|
||||
import androidx.appcompat.widget.TooltipCompat
|
||||
import androidx.core.view.isVisible
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
|
||||
import org.koitharu.kotatsu.core.util.ext.setTooltipCompat
|
||||
import org.koitharu.kotatsu.databinding.ItemChapterGridBinding
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
@@ -23,7 +23,7 @@ fun chapterGridItemAD(
|
||||
bind { payloads ->
|
||||
if (payloads.isEmpty()) {
|
||||
binding.textViewTitle.text = item.chapter.numberString() ?: "?"
|
||||
TooltipCompat.setTooltipText(itemView, item.chapter.title)
|
||||
itemView.setTooltipCompat(item.chapter.title)
|
||||
}
|
||||
binding.imageViewNew.isVisible = item.isNew
|
||||
binding.imageViewCurrent.isVisible = item.isCurrent
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.core.view.MenuProvider
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.slider.LabelFormatter
|
||||
import com.google.android.material.slider.Slider
|
||||
import com.google.android.material.slider.TickVisibilityMode
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||
@@ -38,9 +39,13 @@ class ChapterPagesMenuProvider(
|
||||
setOnActionExpandListener(this@ChapterPagesMenuProvider)
|
||||
(actionView as? SearchView)?.setupChaptersSearchView()
|
||||
}
|
||||
menu.findItem(R.id.action_search)?.isVisible = viewModel.isChaptersEmpty.value == false
|
||||
menu.findItem(R.id.action_search)?.isVisible = viewModel.emptyReason.value == null
|
||||
menu.findItem(R.id.action_reversed)?.isChecked = viewModel.isChaptersReversed.value == true
|
||||
menu.findItem(R.id.action_grid_view)?.isChecked = viewModel.isChaptersInGridView.value == true
|
||||
menu.findItem(R.id.action_downloaded)?.let { menuItem ->
|
||||
menuItem.isVisible = viewModel.mangaDetails.value?.local != null
|
||||
menuItem.isChecked = viewModel.isDownloadedOnly.value == true
|
||||
}
|
||||
}
|
||||
|
||||
TAB_PAGES, TAB_BOOKMARKS -> {
|
||||
@@ -64,6 +69,11 @@ class ChapterPagesMenuProvider(
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_downloaded -> {
|
||||
viewModel.isDownloadedOnly.value = !menuItem.isChecked
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
@@ -110,7 +120,7 @@ class ChapterPagesMenuProvider(
|
||||
valueFrom = 50f
|
||||
valueTo = 150f
|
||||
stepSize = 5f
|
||||
isTickVisible = false
|
||||
tickVisibilityMode = TickVisibilityMode.TICK_VISIBILITY_HIDDEN
|
||||
labelBehavior = LabelFormatter.LABEL_FLOATING
|
||||
setLabelFormatter(IntPercentLabelFormatter(context))
|
||||
setValueRounded(settings.gridSizePages.toFloat())
|
||||
|
||||
@@ -81,6 +81,7 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(),
|
||||
val menuInvalidator = MenuInvalidator(binding.toolbar)
|
||||
viewModel.isChaptersReversed.observe(viewLifecycleOwner, menuInvalidator)
|
||||
viewModel.isChaptersInGridView.observe(viewLifecycleOwner, menuInvalidator)
|
||||
viewModel.isDownloadedOnly.observe(viewLifecycleOwner, menuInvalidator)
|
||||
|
||||
actionModeDelegate?.addListener(this, viewLifecycleOwner)
|
||||
addSheetCallback(this, viewLifecycleOwner)
|
||||
|
||||
@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.plus
|
||||
@@ -43,6 +44,7 @@ import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||
@@ -87,6 +89,8 @@ abstract class ChaptersPagesViewModel(
|
||||
valueProducer = { isChaptersGridView },
|
||||
)
|
||||
|
||||
val isDownloadedOnly = MutableStateFlow(false)
|
||||
|
||||
val newChaptersCount = mangaDetails.flatMapLatest { d ->
|
||||
if (d?.isLocal == false) {
|
||||
interactor.observeNewChapters(d.id)
|
||||
@@ -95,9 +99,19 @@ abstract class ChaptersPagesViewModel(
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
|
||||
|
||||
val isChaptersEmpty: StateFlow<Boolean> = mangaDetails.map {
|
||||
it != null && it.isLoaded && it.allChapters.isEmpty()
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
|
||||
val emptyReason: StateFlow<EmptyMangaReason?> = combine(
|
||||
mangaDetails,
|
||||
isLoading,
|
||||
onError.onStart { emit(null) },
|
||||
) { details, loading, error ->
|
||||
when {
|
||||
details == null || loading -> null
|
||||
details.chapters.isNotEmpty() -> null
|
||||
details.toManga().state == MangaState.RESTRICTED -> EmptyMangaReason.RESTRICTED
|
||||
error != null -> EmptyMangaReason.LOADING_ERROR
|
||||
else -> EmptyMangaReason.NO_CHAPTERS
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(), null)
|
||||
|
||||
val bookmarks = mangaDetails.flatMapLatest {
|
||||
if (it != null) {
|
||||
@@ -115,13 +129,15 @@ abstract class ChaptersPagesViewModel(
|
||||
newChaptersCount,
|
||||
bookmarks,
|
||||
isChaptersInGridView,
|
||||
) { manga, currentChapterId, branch, news, bookmarks, grid ->
|
||||
isDownloadedOnly,
|
||||
) { manga, currentChapterId, branch, news, bookmarks, grid, downloadedOnly ->
|
||||
manga?.mapChapters(
|
||||
currentChapterId,
|
||||
news,
|
||||
branch,
|
||||
bookmarks,
|
||||
grid,
|
||||
currentChapterId = currentChapterId,
|
||||
newCount = news,
|
||||
branch = branch,
|
||||
bookmarks = bookmarks,
|
||||
isGrid = grid,
|
||||
isDownloadedOnly = downloadedOnly,
|
||||
).orEmpty()
|
||||
},
|
||||
isChaptersReversed,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
enum class EmptyMangaReason(
|
||||
@StringRes val msgResId: Int,
|
||||
) {
|
||||
|
||||
NO_CHAPTERS(R.string.no_chapters_in_manga),
|
||||
LOADING_ERROR(R.string.chapters_load_failed),
|
||||
RESTRICTED(R.string.manga_restricted_description),
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.findParentCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
|
||||
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
|
||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
|
||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
|
||||
@@ -96,8 +97,8 @@ class ChaptersFragment :
|
||||
.flowOn(Dispatchers.Default)
|
||||
.observe(viewLifecycleOwner, this::onChaptersChanged)
|
||||
viewModel.quickFilter.observe(viewLifecycleOwner, this::onFilterChanged)
|
||||
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
|
||||
binding.textViewHolder.isVisible = it
|
||||
viewModel.emptyReason.observe(viewLifecycleOwner) {
|
||||
binding.textViewHolder.setTextAndVisible(it?.msgResId ?: 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ import org.koitharu.kotatsu.core.util.MimeTypes
|
||||
import org.koitharu.kotatsu.core.util.ext.fetch
|
||||
import org.koitharu.kotatsu.core.util.ext.isNetworkUri
|
||||
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageCache
|
||||
import org.koitharu.kotatsu.local.data.PageCache
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.util.mimeType
|
||||
import org.koitharu.kotatsu.parsers.util.requireBody
|
||||
@@ -34,7 +35,7 @@ import javax.inject.Inject
|
||||
|
||||
class MangaPageFetcher(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val pagesCache: PagesCache,
|
||||
private val pagesCache: LocalStorageCache,
|
||||
private val options: Options,
|
||||
private val page: MangaPage,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
@@ -53,7 +54,7 @@ class MangaPageFetcher(
|
||||
val repo = mangaRepositoryFactory.create(page.source)
|
||||
val pageUrl = repo.getPageUrl(page)
|
||||
if (options.diskCachePolicy.readEnabled) {
|
||||
pagesCache.get(pageUrl)?.let { file ->
|
||||
pagesCache[pageUrl]?.let { file ->
|
||||
return SourceFetchResult(
|
||||
source = ImageSource(file.toOkioPath(), options.fileSystem),
|
||||
mimeType = MimeTypes.getMimeTypeFromExtension(file.name)?.toString(),
|
||||
@@ -78,7 +79,7 @@ class MangaPageFetcher(
|
||||
}
|
||||
val mimeType = response.mimeType?.toMimeTypeOrNull()
|
||||
val file = response.requireBody().use {
|
||||
pagesCache.put(pageUrl, it.source(), mimeType)
|
||||
pagesCache.set(pageUrl, it.source(), mimeType)
|
||||
}
|
||||
SourceFetchResult(
|
||||
source = ImageSource(file.toOkioPath(), FileSystem.SYSTEM),
|
||||
@@ -107,7 +108,7 @@ class MangaPageFetcher(
|
||||
|
||||
class Factory @Inject constructor(
|
||||
@MangaHttpClient private val okHttpClient: OkHttpClient,
|
||||
private val pagesCache: PagesCache,
|
||||
@PageCache private val pagesCache: LocalStorageCache,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val imageProxyInterceptor: ImageProxyInterceptor,
|
||||
) : Fetcher.Factory<MangaPage> {
|
||||
|
||||
@@ -11,7 +11,6 @@ import androidx.appcompat.view.ActionMode
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -37,9 +36,11 @@ import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.findParentCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
|
||||
import org.koitharu.kotatsu.core.util.ext.showOrHide
|
||||
import org.koitharu.kotatsu.databinding.FragmentPagesBinding
|
||||
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
|
||||
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
|
||||
import org.koitharu.kotatsu.list.ui.GridSpanResolver
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
@@ -125,11 +126,18 @@ class PagesFragment :
|
||||
it.spanCount = checkNotNull(spanResolver).spanCount
|
||||
}
|
||||
}
|
||||
parentViewModel.isChaptersEmpty.observe(viewLifecycleOwner, ::onNoChaptersChanged)
|
||||
parentViewModel.emptyReason.observe(viewLifecycleOwner, ::onNoChaptersChanged)
|
||||
viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged)
|
||||
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(binding.recyclerView))
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
|
||||
viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) }
|
||||
combine(
|
||||
viewModel.isLoading,
|
||||
viewModel.thumbnails,
|
||||
) { loading, content ->
|
||||
loading && content.isEmpty()
|
||||
}.observe(viewLifecycleOwner) {
|
||||
binding.progressBar.showOrHide(it)
|
||||
}
|
||||
viewModel.isLoadingUp.observe(viewLifecycleOwner) { binding.progressBarTop.showOrHide(it) }
|
||||
viewModel.isLoadingDown.observe(viewLifecycleOwner) { binding.progressBarBottom.showOrHide(it) }
|
||||
}
|
||||
@@ -237,10 +245,10 @@ class PagesFragment :
|
||||
spanResolver?.setGridSize(scale, requireViewBinding().recyclerView)
|
||||
}
|
||||
|
||||
private fun onNoChaptersChanged(isNoChapters: Boolean) {
|
||||
private fun onNoChaptersChanged(reason: EmptyMangaReason?) {
|
||||
with(viewBinding ?: return) {
|
||||
textViewHolder.isVisible = isNoChapters
|
||||
recyclerView.isInvisible = isNoChapters
|
||||
textViewHolder.setTextAndVisible(reason?.msgResId ?: 0)
|
||||
recyclerView.isInvisible = reason != null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
@@ -47,16 +48,15 @@ class PagesViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
init {
|
||||
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
||||
val firstState = state.firstNotNull()
|
||||
doInit(firstState)
|
||||
launchJob(Dispatchers.Default) {
|
||||
state.collectLatest {
|
||||
if (it != null) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
state.filterNotNull()
|
||||
.collect {
|
||||
val prevJob = loadingJob
|
||||
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
doInit(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
@@ -25,6 +26,8 @@ import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
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 javax.inject.Inject
|
||||
|
||||
@@ -35,7 +38,8 @@ class RelatedListViewModel @Inject constructor(
|
||||
settings: AppSettings,
|
||||
private val mangaListMapper: MangaListMapper,
|
||||
mangaDataRepository: MangaDataRepository,
|
||||
) : MangaListViewModel(settings, mangaDataRepository) {
|
||||
@LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>,
|
||||
) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges) {
|
||||
|
||||
private val seed = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga
|
||||
private val repository = mangaRepositoryFactory.create(seed.source)
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.Context
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
@@ -64,16 +65,20 @@ import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.getWorkInputData
|
||||
import org.koitharu.kotatsu.core.util.ext.getWorkSpec
|
||||
import org.koitharu.kotatsu.core.util.ext.openSource
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
||||
import org.koitharu.kotatsu.core.util.ext.toMimeType
|
||||
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
|
||||
import org.koitharu.kotatsu.core.util.ext.withTicker
|
||||
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
||||
import org.koitharu.kotatsu.core.util.progress.RealtimeEtaEstimator
|
||||
import org.koitharu.kotatsu.download.domain.DownloadProgress
|
||||
import org.koitharu.kotatsu.download.domain.DownloadState
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageCache
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.local.data.PageCache
|
||||
import org.koitharu.kotatsu.local.data.TempFileFilter
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaParser
|
||||
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
||||
@@ -99,7 +104,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
@Assisted appContext: Context,
|
||||
@Assisted params: WorkerParameters,
|
||||
@MangaHttpClient private val okHttp: OkHttpClient,
|
||||
private val cache: PagesCache,
|
||||
@PageCache private val cache: LocalStorageCache,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
private val mangaLock: MangaLock,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
@@ -197,7 +202,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
?: error("Cannot obtain remote manga instance")
|
||||
}
|
||||
val repo = mangaRepositoryFactory.create(manga.source)
|
||||
val mangaDetails = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
|
||||
val mangaDetails = if (manga.chapters.isNullOrEmpty() || manga.description.isNullOrEmpty()) repo.getDetails(manga) else manga
|
||||
output = LocalMangaOutput.getOrCreate(
|
||||
root = destination,
|
||||
manga = mangaDetails,
|
||||
@@ -229,7 +234,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
semaphore.withPermit {
|
||||
runFailsafe {
|
||||
val url = repo.getPageUrl(page)
|
||||
val file = cache.get(url)
|
||||
val file = cache[url]
|
||||
?: downloadFile(url, destination, repo.source)
|
||||
output.addPage(
|
||||
chapter = chapter,
|
||||
@@ -371,6 +376,25 @@ class DownloadWorker @AssistedInject constructor(
|
||||
destination: File,
|
||||
source: MangaSource,
|
||||
): File {
|
||||
if (url.startsWith("content:", ignoreCase = true) || url.startsWith("file:", ignoreCase = true)) {
|
||||
val uri = url.toUri()
|
||||
val cr = applicationContext.contentResolver
|
||||
val ext = uri.toFileOrNull()?.let {
|
||||
MimeTypes.getNormalizedExtension(it.name)
|
||||
} ?: cr.getType(uri)?.toMimeTypeOrNull()?.let { MimeTypes.getExtension(it) }
|
||||
val file = destination.createTempFile(ext)
|
||||
try {
|
||||
cr.openSource(uri).use { input ->
|
||||
file.sink(append = false).buffer().use {
|
||||
it.writeAllCancellable(input)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
file.delete()
|
||||
throw e
|
||||
}
|
||||
return file
|
||||
}
|
||||
val request = PageLoader.createPageRequest(url, source)
|
||||
slowdownDispatcher.delay(source)
|
||||
return imageProxyInterceptor.interceptPageRequest(request, okHttp)
|
||||
@@ -379,22 +403,14 @@ class DownloadWorker @AssistedInject constructor(
|
||||
var file: File? = null
|
||||
try {
|
||||
response.requireBody().use { body ->
|
||||
file = File(
|
||||
destination,
|
||||
buildString {
|
||||
append(UUID.randomUUID().toString())
|
||||
MimeTypes.getExtension(body.contentType()?.toMimeType())?.let { ext ->
|
||||
append('.')
|
||||
append(ext)
|
||||
}
|
||||
append(".tmp")
|
||||
},
|
||||
file = destination.createTempFile(
|
||||
ext = MimeTypes.getExtension(body.contentType()?.toMimeType())
|
||||
)
|
||||
file.sink(append = false).buffer().use {
|
||||
it.writeAllCancellable(body.source())
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
} catch (e: Exception) {
|
||||
file?.delete()
|
||||
throw e
|
||||
}
|
||||
@@ -402,6 +418,18 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.createTempFile(ext: String?) = File(
|
||||
this,
|
||||
buildString {
|
||||
append(UUID.randomUUID().toString())
|
||||
if (!ext.isNullOrEmpty()) {
|
||||
append('.')
|
||||
append(ext)
|
||||
}
|
||||
append(".tmp")
|
||||
},
|
||||
)
|
||||
|
||||
private suspend fun publishState(state: DownloadState) {
|
||||
val previousState = currentState
|
||||
lastPublishedState = state
|
||||
@@ -537,7 +565,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
return
|
||||
}
|
||||
val requests = tasks.map { (manga, task) ->
|
||||
mangaDataRepository.storeManga(manga)
|
||||
mangaDataRepository.storeManga(manga, replaceExisting = true)
|
||||
OneTimeWorkRequestBuilder<DownloadWorker>()
|
||||
.setConstraints(createConstraints(task.allowMeteredNetwork))
|
||||
.addTag(TAG)
|
||||
|
||||
@@ -53,11 +53,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
get() = db.getSourcesDao()
|
||||
|
||||
val allMangaSources: Set<MangaParserSource> = Collections.unmodifiableSet(
|
||||
EnumSet.allOf(MangaParserSource::class.java).apply {
|
||||
if (!BuildConfig.DEBUG) {
|
||||
remove(MangaParserSource.DUMMY)
|
||||
}
|
||||
},
|
||||
EnumSet.allOf(MangaParserSource::class.java)
|
||||
)
|
||||
|
||||
suspend fun getEnabledSources(): List<MangaSource> {
|
||||
|
||||
@@ -35,7 +35,7 @@ class ExploreRepository @Inject constructor(
|
||||
val details = runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(manga.source).getDetails(manga)
|
||||
}.getOrNull() ?: continue
|
||||
if ((settings.isSuggestionsExcludeNsfw && details.isNsfw) || details in tagsBlacklist) {
|
||||
if ((settings.isSuggestionsExcludeNsfw && details.isNsfw()) || details in tagsBlacklist) {
|
||||
continue
|
||||
}
|
||||
return details
|
||||
@@ -55,7 +55,7 @@ class ExploreRepository @Inject constructor(
|
||||
val details = runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(manga.source).getDetails(manga)
|
||||
}.getOrNull() ?: continue
|
||||
if ((skipNsfw && details.isNsfw) || details in tagsBlacklist) {
|
||||
if ((skipNsfw && details.isNsfw()) || details in tagsBlacklist) {
|
||||
continue
|
||||
}
|
||||
return details
|
||||
@@ -80,7 +80,7 @@ class ExploreRepository @Inject constructor(
|
||||
filter = MangaListFilter(tags = setOfNotNull(tag)),
|
||||
).asArrayList()
|
||||
if (settings.isSuggestionsExcludeNsfw) {
|
||||
list.removeAll { it.isNsfw }
|
||||
list.removeAll { it.isNsfw() }
|
||||
}
|
||||
if (blacklist.isNotEmpty()) {
|
||||
list.removeAll { manga -> manga in blacklist }
|
||||
|
||||
@@ -24,7 +24,7 @@ class RecoverMangaUseCase @Inject constructor(
|
||||
repository.getDetails(it)
|
||||
} ?: return@runCatchingCancellable null
|
||||
val merged = merge(manga, newManga)
|
||||
mangaDataRepository.storeManga(merged)
|
||||
mangaDataRepository.storeManga(merged, replaceExisting = true)
|
||||
merged
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
|
||||
@@ -198,11 +198,9 @@ class ExploreViewModel @Inject constructor(
|
||||
|
||||
private fun List<Manga>.toRecommendationList() = map { manga ->
|
||||
MangaCompactListModel(
|
||||
id = manga.id,
|
||||
title = manga.title,
|
||||
subtitle = manga.tags.joinToString { it.title },
|
||||
coverUrl = manga.coverUrl,
|
||||
manga = manga,
|
||||
override = null,
|
||||
subtitle = manga.tags.joinToString { it.title },
|
||||
counter = 0,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.drawableStart
|
||||
import org.koitharu.kotatsu.core.util.ext.recyclerView
|
||||
import org.koitharu.kotatsu.core.util.ext.setProgressIcon
|
||||
import org.koitharu.kotatsu.core.util.ext.setTooltipCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemExploreSourceGridBinding
|
||||
@@ -126,8 +127,7 @@ fun exploreSourceGridItemAD(
|
||||
|
||||
bind {
|
||||
val title = item.source.getTitle(context)
|
||||
TooltipCompat.setTooltipText(
|
||||
itemView,
|
||||
itemView.setTooltipCompat(
|
||||
buildSpannedString {
|
||||
bold {
|
||||
append(title)
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.core.view.isVisible
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
||||
import org.koitharu.kotatsu.core.util.ext.setTooltipCompat
|
||||
import org.koitharu.kotatsu.databinding.ItemCategoriesAllBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemCategoryBinding
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
|
||||
@@ -91,6 +92,13 @@ fun allCategoriesAD(
|
||||
R.drawable.ic_eye_off
|
||||
},
|
||||
)
|
||||
binding.imageViewVisible.setTooltipCompat(
|
||||
if (item.isVisible) {
|
||||
R.string.hide
|
||||
} else {
|
||||
R.string.show
|
||||
},
|
||||
)
|
||||
binding.coversView.setCoversAsync(item.covers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
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
|
||||
|
||||
@@ -52,7 +55,8 @@ class FavouritesListViewModel @Inject constructor(
|
||||
quickFilterFactory: FavoritesListQuickFilter.Factory,
|
||||
settings: AppSettings,
|
||||
mangaDataRepository: MangaDataRepository,
|
||||
) : MangaListViewModel(settings, mangaDataRepository), QuickFilterListener {
|
||||
@LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>,
|
||||
) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges), QuickFilterListener {
|
||||
|
||||
val categoryId: Long = savedStateHandle[AppRouter.KEY_ID] ?: NO_ID
|
||||
private val quickFilter = quickFilterFactory.create(categoryId)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.filter.ui
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.ViewModelLifecycle
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
@@ -489,9 +490,27 @@ class FilterCoordinator @Inject constructor(
|
||||
val filterCoordinator: FilterCoordinator
|
||||
}
|
||||
|
||||
private companion object {
|
||||
companion object {
|
||||
|
||||
const val TAGS_LIMIT = 12
|
||||
val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1
|
||||
}
|
||||
private const val TAGS_LIMIT = 12
|
||||
private val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1
|
||||
|
||||
fun find(fragment: Fragment): FilterCoordinator? {
|
||||
(fragment.activity as? Owner)?.let {
|
||||
return it.filterCoordinator
|
||||
}
|
||||
var f = fragment
|
||||
while (true) {
|
||||
(f as? Owner)?.let {
|
||||
return it.filterCoordinator
|
||||
}
|
||||
f = f.parentFragment ?: break
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun require(fragment: Fragment): FilterCoordinator {
|
||||
return find(fragment) ?: throw IllegalStateException("FilterCoordinator cannot be found")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,11 +51,9 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
if (dialog == null) {
|
||||
binding.layoutBody.updatePadding(top = binding.layoutBody.paddingBottom)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
binding.scrollView.scrollIndicators = 0
|
||||
}
|
||||
binding.scrollView.scrollIndicators = 0
|
||||
}
|
||||
val filter = requireFilter()
|
||||
val filter = FilterCoordinator.require(this)
|
||||
filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
|
||||
filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged)
|
||||
filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged)
|
||||
@@ -103,7 +101,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
}
|
||||
|
||||
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
|
||||
val filter = requireFilter()
|
||||
val filter = FilterCoordinator.require(this)
|
||||
when (parent.id) {
|
||||
R.id.spinner_order -> filter.setSortOrder(filter.sortOrder.value.availableItems[position])
|
||||
R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position])
|
||||
@@ -118,7 +116,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
return
|
||||
}
|
||||
val intValue = value.toInt()
|
||||
val filter = requireFilter()
|
||||
val filter = FilterCoordinator.require(this)
|
||||
when (slider.id) {
|
||||
R.id.slider_year -> filter.setYear(
|
||||
if (intValue <= slider.valueFrom.toIntUp()) {
|
||||
@@ -134,7 +132,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
if (!fromUser) {
|
||||
return
|
||||
}
|
||||
val filter = requireFilter()
|
||||
val filter = FilterCoordinator.require(this)
|
||||
when (slider.id) {
|
||||
R.id.slider_yearsRange -> filter.setYearRange(
|
||||
valueFrom = slider.values.firstOrNull()?.let {
|
||||
@@ -148,7 +146,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
}
|
||||
|
||||
override fun onChipClick(chip: Chip, data: Any?) {
|
||||
val filter = requireFilter()
|
||||
val filter = FilterCoordinator.require(this)
|
||||
when (data) {
|
||||
is MangaState -> filter.toggleState(data, !chip.isChecked)
|
||||
is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) {
|
||||
@@ -356,6 +354,4 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
)
|
||||
b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo)
|
||||
}
|
||||
|
||||
private fun requireFilter() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class TagsCatalogSheet : BaseAdaptiveSheet<SheetTagsBinding>(),
|
||||
extrasProducer = {
|
||||
defaultViewModelCreationExtras.withCreationCallback<TagsCatalogViewModel.Factory> { factory ->
|
||||
factory.create(
|
||||
filter = (requireActivity() as FilterCoordinator.Owner).filterCoordinator,
|
||||
filter = FilterCoordinator.require(this),
|
||||
isExcludeTag = requireArguments().getBoolean(AppRouter.KEY_EXCLUDE),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ import androidx.room.withTransaction
|
||||
import dagger.Reusable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.toEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||
@@ -118,7 +116,7 @@ class HistoryRepository @Inject constructor(
|
||||
}
|
||||
assert(manga.chapters != null)
|
||||
db.withTransaction {
|
||||
mangaRepository.storeManga(manga)
|
||||
mangaRepository.storeManga(manga, replaceExisting = true)
|
||||
val branch = manga.chapters?.findById(chapterId)?.branch
|
||||
db.getHistoryDao().upsert(
|
||||
HistoryEntity(
|
||||
@@ -204,9 +202,7 @@ class HistoryRepository @Inject constructor(
|
||||
fun shouldSkip(manga: Manga): Boolean = settings.isIncognitoModeEnabled(manga.isNsfw())
|
||||
|
||||
fun observeShouldSkip(manga: Manga): Flow<Boolean> {
|
||||
return settings.observe()
|
||||
.filter { key -> key == AppSettings.KEY_INCOGNITO_MODE || key == AppSettings.KEY_INCOGNITO_NSFW }
|
||||
.onStart { emit("") }
|
||||
return settings.observe(AppSettings.KEY_INCOGNITO_MODE, AppSettings.KEY_INCOGNITO_NSFW)
|
||||
.map { shouldSkip(manga) }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
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
|
||||
|
||||
@@ -54,7 +57,8 @@ class HistoryListViewModel @Inject constructor(
|
||||
private val markAsReadUseCase: MarkAsReadUseCase,
|
||||
private val quickFilter: HistoryListQuickFilter,
|
||||
mangaDataRepository: MangaDataRepository,
|
||||
) : MangaListViewModel(settings, mangaDataRepository), QuickFilterListener by quickFilter {
|
||||
@LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>,
|
||||
) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges), QuickFilterListener by quickFilter {
|
||||
|
||||
private val sortOrder: StateFlow<ListSortOrder> = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.IO,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package org.koitharu.kotatsu.image.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.ViewTreeObserver.OnPreDrawListener
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
@@ -33,6 +36,7 @@ import org.koitharu.kotatsu.core.ui.image.TrimTransformation
|
||||
import org.koitharu.kotatsu.core.util.ext.bookmarkExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.isNetworkError
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||
import org.koitharu.kotatsu.favourites.domain.model.Cover
|
||||
@@ -79,9 +83,7 @@ class CoverImageView @JvmOverloads constructor(
|
||||
if (fallbackDrawable == null) {
|
||||
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) {
|
||||
@@ -165,7 +167,6 @@ class CoverImageView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private inner class ErrorForegroundListener : ImageRequest.Listener {
|
||||
|
||||
override fun onSuccess(request: ImageRequest, result: SuccessResult) {
|
||||
@@ -185,8 +186,17 @@ class CoverImageView @JvmOverloads constructor(
|
||||
|
||||
override fun onError(request: ImageRequest, result: ErrorResult) {
|
||||
super.onError(request, result)
|
||||
foreground = result.throwable.getShortMessage()?.let { text ->
|
||||
TextDrawable.create(context, text, materialR.attr.textAppearanceTitleSmall)
|
||||
foreground = if (result.throwable.isNetworkError() && !networkState.isOnline()) {
|
||||
ContextCompat.getDrawable(context, R.drawable.ic_offline)?.let {
|
||||
LayerDrawable(arrayOf(it)).apply {
|
||||
setLayerGravity(0, Gravity.CENTER)
|
||||
setTint(ContextCompat.getColor(context, R.color.dim_lite))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.throwable.getShortMessage()?.let { text ->
|
||||
TextDrawable.create(context, text, materialR.attr.textAppearanceTitleSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,9 @@ import org.koitharu.kotatsu.list.ui.model.MangaListModel
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
|
||||
import org.koitharu.kotatsu.tracker.ui.feed.model.FeedItem
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
@@ -77,6 +78,14 @@ class MangaListMapper @Inject constructor(
|
||||
override = dataRepository.getOverride(manga.id),
|
||||
)
|
||||
|
||||
suspend fun toFeedItem(logItem: TrackingLogItem) = FeedItem(
|
||||
id = logItem.id,
|
||||
override = dataRepository.getOverride(logItem.manga.id),
|
||||
count = logItem.chapters.size,
|
||||
manga = logItem.manga,
|
||||
isNew = logItem.isNew,
|
||||
)
|
||||
|
||||
fun mapTags(tags: Collection<MangaTag>) = tags.map {
|
||||
ChipsView.ChipModel(
|
||||
tint = getTagTint(it),
|
||||
@@ -90,11 +99,9 @@ class MangaListMapper @Inject constructor(
|
||||
@Options options: Int,
|
||||
override: MangaOverride?,
|
||||
) = MangaCompactListModel(
|
||||
id = manga.id,
|
||||
title = override?.title.ifNullOrEmpty { manga.title },
|
||||
subtitle = manga.tags.joinToString(", ") { it.title },
|
||||
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
|
||||
manga = manga,
|
||||
override = override,
|
||||
subtitle = manga.tags.joinToString(", ") { it.title },
|
||||
counter = getCounter(manga.id, options),
|
||||
)
|
||||
|
||||
@@ -103,11 +110,9 @@ class MangaListMapper @Inject constructor(
|
||||
@Options options: Int,
|
||||
override: MangaOverride?,
|
||||
) = MangaDetailedListModel(
|
||||
id = manga.id,
|
||||
title = override?.title.ifNullOrEmpty { manga.title },
|
||||
subtitle = manga.altTitles.firstOrNull(),
|
||||
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
|
||||
manga = manga,
|
||||
override = override,
|
||||
counter = getCounter(manga.id, options),
|
||||
progress = getProgress(manga.id, options),
|
||||
isFavorite = isFavorite(manga.id, options),
|
||||
@@ -120,10 +125,8 @@ class MangaListMapper @Inject constructor(
|
||||
@Options options: Int,
|
||||
override: MangaOverride?
|
||||
) = MangaGridModel(
|
||||
id = manga.id,
|
||||
title = override?.title.ifNullOrEmpty { manga.title },
|
||||
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
|
||||
manga = manga,
|
||||
override = override,
|
||||
counter = getCounter(manga.id, options),
|
||||
progress = getProgress(manga.id, options),
|
||||
isFavorite = isFavorite(manga.id, options),
|
||||
|
||||
@@ -153,19 +153,20 @@ abstract class MangaListFragment :
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onItemClick(item: Manga, view: View) {
|
||||
override fun onItemClick(item: MangaListModel, view: View) {
|
||||
if (selectionController?.onItemClick(item.id) != true) {
|
||||
if ((activity as? MangaListActivity)?.showPreview(item) != true) {
|
||||
router.openDetails(item)
|
||||
val manga = item.toMangaWithOverride()
|
||||
if ((activity as? MangaListActivity)?.showPreview(manga) != true) {
|
||||
router.openDetails(manga)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: Manga, view: View): Boolean {
|
||||
override fun onItemLongClick(item: MangaListModel, view: View): Boolean {
|
||||
return selectionController?.onItemLongClick(view, item.id) == true
|
||||
}
|
||||
|
||||
override fun onItemContextClick(item: Manga, view: View): Boolean {
|
||||
override fun onItemContextClick(item: MangaListModel, view: View): Boolean {
|
||||
return selectionController?.onItemContextClick(view, item.id) == true
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user