Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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/vcs.xml
generated
2
.idea/vcs.xml
generated
@@ -10,6 +10,6 @@
|
|||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
<mapping directory="" vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -8,19 +8,21 @@ plugins {
|
|||||||
id 'dagger.hilt.android.plugin'
|
id 'dagger.hilt.android.plugin'
|
||||||
id 'androidx.room'
|
id 'androidx.room'
|
||||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||||
|
// enable if needed
|
||||||
|
// id 'dev.reformator.stacktracedecoroutinator'
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
buildToolsVersion = '35.0.0'
|
buildToolsVersion = '35.0.0'
|
||||||
namespace = 'org.koitharu.kotatsu'
|
namespace = 'org.koitharu.kotatsu'
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdk = 21
|
minSdk = 21
|
||||||
targetSdk = 35
|
targetSdk = 36
|
||||||
versionCode = 1022
|
versionCode = 1027
|
||||||
versionName = '9.0'
|
versionName = '9.1.3'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||||
ksp {
|
ksp {
|
||||||
@@ -30,6 +32,12 @@ android {
|
|||||||
// https://issuetracker.google.com/issues/408030127
|
// https://issuetracker.google.com/issues/408030127
|
||||||
generateLocaleConfig false
|
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 {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
@@ -172,6 +180,7 @@ dependencies {
|
|||||||
implementation libs.ssiv
|
implementation libs.ssiv
|
||||||
implementation libs.disk.lru.cache
|
implementation libs.disk.lru.cache
|
||||||
implementation libs.markwon
|
implementation libs.markwon
|
||||||
|
implementation libs.kizzyrpc
|
||||||
|
|
||||||
implementation libs.acra.http
|
implementation libs.acra.http
|
||||||
implementation libs.acra.dialog
|
implementation libs.acra.dialog
|
||||||
@@ -179,6 +188,7 @@ dependencies {
|
|||||||
implementation libs.conscrypt.android
|
implementation libs.conscrypt.android
|
||||||
|
|
||||||
debugImplementation libs.leakcanary.android
|
debugImplementation libs.leakcanary.android
|
||||||
|
nightlyImplementation libs.leakcanary.android
|
||||||
debugImplementation libs.workinspector
|
debugImplementation libs.workinspector
|
||||||
|
|
||||||
testImplementation libs.junit
|
testImplementation libs.junit
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ class KotatsuApp : BaseApp() {
|
|||||||
detectLeakedSqlLiteObjects()
|
detectLeakedSqlLiteObjects()
|
||||||
detectLeakedClosableObjects()
|
detectLeakedClosableObjects()
|
||||||
detectLeakedRegistrationObjects()
|
detectLeakedRegistrationObjects()
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectContentUriWithoutPermission()
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
detectContentUriWithoutPermission()
|
||||||
|
}
|
||||||
detectFileUriExposure()
|
detectFileUriExposure()
|
||||||
penaltyLog()
|
penaltyLog()
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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.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
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
17
app/src/debug/res/xml/pref_debug.xml
Normal file
17
app/src/debug/res/xml/pref_debug.xml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.preference.PreferenceScreen
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
|
||||||
|
android:id="@+id/action_leakcanary"
|
||||||
|
android:key="leak_canary"
|
||||||
|
android:persistent="false"
|
||||||
|
android:title="LeakCanary" />
|
||||||
|
|
||||||
|
<Preference
|
||||||
|
android:id="@+id/action_works"
|
||||||
|
android:key="work_inspector"
|
||||||
|
android:persistent="false"
|
||||||
|
android:title="@string/wi_lib_name" />
|
||||||
|
|
||||||
|
</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>
|
||||||
@@ -287,6 +287,9 @@
|
|||||||
<data android:mimeType="image/*" />
|
<data android:mimeType="image/*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name="org.koitharu.kotatsu.scrobbling.discord.ui.DiscordAuthActivity"
|
||||||
|
android:label="@string/discord" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||||
@@ -404,6 +407,13 @@
|
|||||||
tools:node="remove" />
|
tools:node="remove" />
|
||||||
</provider>
|
</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
|
<receiver
|
||||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider"
|
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -30,21 +30,19 @@ constructor(
|
|||||||
oldManga: Manga,
|
oldManga: Manga,
|
||||||
newManga: Manga,
|
newManga: Manga,
|
||||||
) {
|
) {
|
||||||
val oldDetails =
|
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
|
||||||
if (oldManga.chapters.isNullOrEmpty()) {
|
runCatchingCancellable {
|
||||||
runCatchingCancellable {
|
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
}.getOrDefault(oldManga)
|
||||||
}.getOrDefault(oldManga)
|
} else {
|
||||||
} else {
|
oldManga
|
||||||
oldManga
|
}
|
||||||
}
|
val newDetails = if (newManga.chapters.isNullOrEmpty()) {
|
||||||
val newDetails =
|
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
||||||
if (newManga.chapters.isNullOrEmpty()) {
|
} else {
|
||||||
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
newManga
|
||||||
} else {
|
}
|
||||||
newManga
|
mangaDataRepository.storeManga(newDetails, replaceExisting = true)
|
||||||
}
|
|
||||||
mangaDataRepository.storeManga(newDetails)
|
|
||||||
database.withTransaction {
|
database.withTransaction {
|
||||||
// replace favorites
|
// replace favorites
|
||||||
val favoritesDao = database.getFavouritesDao()
|
val favoritesDao = database.getFavouritesDao()
|
||||||
@@ -101,11 +99,11 @@ constructor(
|
|||||||
mangaId = newDetails.id,
|
mangaId = newDetails.id,
|
||||||
rating = prevInfo.rating,
|
rating = prevInfo.rating,
|
||||||
status =
|
status =
|
||||||
prevInfo.status ?: when {
|
prevInfo.status ?: when {
|
||||||
newHistory == null -> ScrobblingStatus.PLANNED
|
newHistory == null -> ScrobblingStatus.PLANNED
|
||||||
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
|
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
|
||||||
else -> ScrobblingStatus.READING
|
else -> ScrobblingStatus.READING
|
||||||
},
|
},
|
||||||
comment = prevInfo.comment,
|
comment = prevInfo.comment,
|
||||||
)
|
)
|
||||||
if (newHistory != null) {
|
if (newHistory != null) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
|
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.ErrorReporterReceiver
|
||||||
import org.koitharu.kotatsu.core.model.getTitle
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
import org.koitharu.kotatsu.core.model.isNsfw
|
import org.koitharu.kotatsu.core.model.isNsfw
|
||||||
@@ -47,7 +48,7 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
notificationManager = NotificationManagerCompat.from(applicationContext)
|
notificationManager = NotificationManagerCompat.from(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||||
@@ -58,7 +59,7 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
val result = runCatchingCancellable {
|
val result = runCatchingCancellable {
|
||||||
autoFixUseCase.invoke(mangaId)
|
autoFixUseCase.invoke(mangaId)
|
||||||
}
|
}
|
||||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
if (checkNotificationPermission(CHANNEL_ID)) {
|
||||||
val notification = buildNotification(startId, result)
|
val notification = buildNotification(startId, result)
|
||||||
notificationManager.notify(TAG, startId, notification)
|
notificationManager.notify(TAG, startId, notification)
|
||||||
}
|
}
|
||||||
@@ -67,7 +68,7 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun IntentJobContext.onError(error: Throwable) {
|
override fun IntentJobContext.onError(error: Throwable) {
|
||||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
if (checkNotificationPermission(CHANNEL_ID)) {
|
||||||
val notification = runBlocking { buildNotification(startId, Result.failure(error)) }
|
val notification = runBlocking { buildNotification(startId, Result.failure(error)) }
|
||||||
notificationManager.notify(TAG, startId, notification)
|
notificationManager.notify(TAG, startId, notification)
|
||||||
}
|
}
|
||||||
@@ -75,7 +76,7 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
|
|
||||||
@SuppressLint("InlinedApi")
|
@SuppressLint("InlinedApi")
|
||||||
private fun startForeground(jobContext: IntentJobContext) {
|
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)
|
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
|
||||||
.setName(title)
|
.setName(title)
|
||||||
.setShowBadge(false)
|
.setShowBadge(false)
|
||||||
@@ -85,7 +86,7 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
.build()
|
.build()
|
||||||
notificationManager.createNotificationChannel(channel)
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
.setDefaults(0)
|
.setDefaults(0)
|
||||||
@@ -97,7 +98,7 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
.addAction(
|
.addAction(
|
||||||
appcompatR.drawable.abc_ic_clear_material,
|
appcompatR.drawable.abc_ic_clear_material,
|
||||||
applicationContext.getString(android.R.string.cancel),
|
getString(android.R.string.cancel),
|
||||||
jobContext.getCancelIntent(),
|
jobContext.getCancelIntent(),
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
@@ -110,7 +111,7 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildNotification(startId: Int, result: Result<Pair<Manga, Manga?>>): Notification {
|
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)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
.setDefaults(0)
|
.setDefaults(0)
|
||||||
.setSilent(true)
|
.setSilent(true)
|
||||||
@@ -119,17 +120,17 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
if (replacement != null) {
|
if (replacement != null) {
|
||||||
notification.setLargeIcon(
|
notification.setLargeIcon(
|
||||||
coil.execute(
|
coil.execute(
|
||||||
ImageRequest.Builder(applicationContext)
|
ImageRequest.Builder(this)
|
||||||
.data(replacement.coverUrl)
|
.data(replacement.coverUrl)
|
||||||
.mangaSourceExtra(replacement.source)
|
.mangaSourceExtra(replacement.source)
|
||||||
.build(),
|
.build(),
|
||||||
).toBitmapOrNull(),
|
).toBitmapOrNull(),
|
||||||
)
|
)
|
||||||
notification.setSubText(replacement.title)
|
notification.setSubText(replacement.title)
|
||||||
val intent = AppRouter.detailsIntent(applicationContext, replacement)
|
val intent = AppRouter.detailsIntent(this, replacement)
|
||||||
notification.setContentIntent(
|
notification.setContentIntent(
|
||||||
PendingIntentCompat.getActivity(
|
PendingIntentCompat.getActivity(
|
||||||
applicationContext,
|
this,
|
||||||
replacement.id.toInt(),
|
replacement.id.toInt(),
|
||||||
intent,
|
intent,
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT,
|
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
@@ -143,35 +144,35 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
notification
|
notification
|
||||||
.setContentTitle(applicationContext.getString(R.string.fixed))
|
.setContentTitle(getString(R.string.fixed))
|
||||||
.setContentText(
|
.setContentText(
|
||||||
applicationContext.getString(
|
getString(
|
||||||
R.string.manga_replaced,
|
R.string.manga_replaced,
|
||||||
seed.title,
|
seed.title,
|
||||||
seed.source.getTitle(applicationContext),
|
seed.source.getTitle(this),
|
||||||
replacement.title,
|
replacement.title,
|
||||||
replacement.source.getTitle(applicationContext),
|
replacement.source.getTitle(this),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.setSmallIcon(R.drawable.ic_stat_done)
|
.setSmallIcon(R.drawable.ic_stat_done)
|
||||||
} else {
|
} else {
|
||||||
notification
|
notification
|
||||||
.setContentTitle(applicationContext.getString(R.string.fixing_manga))
|
.setContentTitle(getString(R.string.fixing_manga))
|
||||||
.setContentText(applicationContext.getString(R.string.no_fix_required, seed.title))
|
.setContentText(getString(R.string.no_fix_required, seed.title))
|
||||||
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||||
}
|
}
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
notification
|
notification
|
||||||
.setContentTitle(applicationContext.getString(R.string.error_occurred))
|
.setContentTitle(getString(R.string.error_occurred))
|
||||||
.setContentText(
|
.setContentText(
|
||||||
if (error is AutoFixUseCase.NoAlternativesException) {
|
if (error is NoAlternativesException) {
|
||||||
applicationContext.getString(R.string.no_alternatives_found, error.seed.manga.title)
|
getString(R.string.no_alternatives_found, error.seed.manga.title)
|
||||||
} else {
|
} else {
|
||||||
error.getDisplayMessage(applicationContext.resources)
|
error.getDisplayMessage(resources)
|
||||||
},
|
},
|
||||||
).setSmallIcon(android.R.drawable.stat_notify_error)
|
).setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
ErrorReporterReceiver.getNotificationAction(
|
ErrorReporterReceiver.getNotificationAction(
|
||||||
context = applicationContext,
|
context = this,
|
||||||
e = error,
|
e = error,
|
||||||
notificationId = startId,
|
notificationId = startId,
|
||||||
notificationTag = TAG,
|
notificationTag = TAG,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.backups.ui
|
package org.koitharu.kotatsu.backups.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.app.NotificationChannelCompat
|
import androidx.core.app.NotificationChannelCompat
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
@@ -27,24 +28,13 @@ abstract class BaseBackupRestoreService : CoroutineIntentService() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
notificationManager = NotificationManagerCompat.from(applicationContext)
|
notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||||
createNotificationChannel()
|
createNotificationChannel(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun IntentJobContext.onError(error: Throwable) {
|
override fun IntentJobContext.onError(error: Throwable) {
|
||||||
showResultNotification(null, CompositeResult.failure(error))
|
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(
|
protected fun IntentJobContext.showResultNotification(
|
||||||
fileUri: Uri?,
|
fileUri: Uri?,
|
||||||
result: CompositeResult,
|
result: CompositeResult,
|
||||||
@@ -128,8 +118,19 @@ abstract class BaseBackupRestoreService : CoroutineIntentService() {
|
|||||||
.setBigContentTitle(title),
|
.setBigContentTitle(title),
|
||||||
)
|
)
|
||||||
|
|
||||||
protected companion object {
|
companion object {
|
||||||
|
|
||||||
const val CHANNEL_ID = "backup_restore"
|
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
|
package org.koitharu.kotatsu.backups.ui.periodical
|
||||||
|
|
||||||
import android.content.Intent
|
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 dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.backups.data.BackupRepository
|
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||||
import org.koitharu.kotatsu.backups.domain.BackupUtils
|
import org.koitharu.kotatsu.backups.domain.BackupUtils
|
||||||
import org.koitharu.kotatsu.backups.domain.ExternalBackupStorage
|
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.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
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 java.util.zip.ZipOutputStream
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -40,7 +49,7 @@ class PeriodicalBackupService : CoroutineIntentService() {
|
|||||||
}
|
}
|
||||||
externalBackupStorage.put(output)
|
externalBackupStorage.put(output)
|
||||||
externalBackupStorage.trim(settings.periodicalBackupMaxCount)
|
externalBackupStorage.trim(settings.periodicalBackupMaxCount)
|
||||||
if (settings.isBackupTelegramUploadEnabled) {
|
if (settings.isBackupTelegramUploadEnabled && telegramBackupUploader.isAvailable) {
|
||||||
telegramBackupUploader.uploadBackup(output)
|
telegramBackupUploader.uploadBackup(output)
|
||||||
}
|
}
|
||||||
} finally {
|
} 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.fragment.app.viewModels
|
||||||
import androidx.preference.EditTextPreference
|
import androidx.preference.EditTextPreference
|
||||||
import androidx.preference.Preference
|
import androidx.preference.Preference
|
||||||
|
import androidx.preference.PreferenceCategory
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
@@ -37,6 +38,7 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
|
|||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
addPreferencesFromResource(R.xml.pref_backup_periodic)
|
addPreferencesFromResource(R.xml.pref_backup_periodic)
|
||||||
|
findPreference<PreferenceCategory>(AppSettings.KEY_BACKUP_TG)?.isVisible = viewModel.isTelegramAvailable
|
||||||
findPreference<EditTextPreference>(AppSettings.KEY_BACKUP_TG_CHAT)?.summaryProvider =
|
findPreference<EditTextPreference>(AppSettings.KEY_BACKUP_TG_CHAT)?.summaryProvider =
|
||||||
EditTextFallbackSummaryProvider(R.string.telegram_chat_id_summary)
|
EditTextFallbackSummaryProvider(R.string.telegram_chat_id_summary)
|
||||||
}
|
}
|
||||||
@@ -84,6 +86,11 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
|
|||||||
"" -> null
|
"" -> null
|
||||||
else -> path
|
else -> path
|
||||||
}
|
}
|
||||||
|
preference.icon = if (path == null) {
|
||||||
|
getWarningIcon()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bindLastBackupInfo(lastBackupDate: Date?) {
|
private fun bindLastBackupInfo(lastBackupDate: Date?) {
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ class PeriodicalBackupSettingsViewModel @Inject constructor(
|
|||||||
@ApplicationContext private val appContext: Context,
|
@ApplicationContext private val appContext: Context,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val isTelegramAvailable
|
||||||
|
get() = telegramUploader.isAvailable
|
||||||
|
|
||||||
val lastBackupDate = MutableStateFlow<Date?>(null)
|
val lastBackupDate = MutableStateFlow<Date?>(null)
|
||||||
val backupsDirectory = MutableStateFlow<String?>("")
|
val backupsDirectory = MutableStateFlow<String?>("")
|
||||||
val isTelegramCheckLoading = MutableStateFlow(false)
|
val isTelegramCheckLoading = MutableStateFlow(false)
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ class TelegramBackupUploader @Inject constructor(
|
|||||||
|
|
||||||
private val botToken = context.getString(R.string.tg_backup_bot_token)
|
private val botToken = context.getString(R.string.tg_backup_bot_token)
|
||||||
|
|
||||||
|
val isAvailable: Boolean
|
||||||
|
get() = botToken.isNotEmpty()
|
||||||
|
|
||||||
suspend fun uploadBackup(file: File) {
|
suspend fun uploadBackup(file: File) {
|
||||||
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
|
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
|
||||||
val multipartBody = MultipartBody.Builder()
|
val multipartBody = MultipartBody.Builder()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.browser
|
package org.koitharu.kotatsu.browser
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.webkit.WebResourceRequest
|
import android.webkit.WebResourceRequest
|
||||||
@@ -15,7 +16,7 @@ import java.io.ByteArrayInputStream
|
|||||||
|
|
||||||
open class BrowserClient(
|
open class BrowserClient(
|
||||||
private val callback: BrowserCallback,
|
private val callback: BrowserCallback,
|
||||||
private val adBlock: AdBlock,
|
private val adBlock: AdBlock?,
|
||||||
) : WebViewClient() {
|
) : WebViewClient() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,7 +48,7 @@ open class BrowserClient(
|
|||||||
override fun shouldInterceptRequest(
|
override fun shouldInterceptRequest(
|
||||||
view: WebView?,
|
view: WebView?,
|
||||||
url: String?
|
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)
|
super.shouldInterceptRequest(view, url)
|
||||||
} else {
|
} else {
|
||||||
emptyResponse()
|
emptyResponse()
|
||||||
@@ -57,15 +58,17 @@ open class BrowserClient(
|
|||||||
override fun shouldInterceptRequest(
|
override fun shouldInterceptRequest(
|
||||||
view: WebView?,
|
view: WebView?,
|
||||||
request: WebResourceRequest?
|
request: WebResourceRequest?
|
||||||
): WebResourceResponse? = if (request == null || adBlock.shouldLoadUrl(request.url.toString(), view?.getUrlSafe())) {
|
): WebResourceResponse? =
|
||||||
super.shouldInterceptRequest(view, request)
|
if (request == null || adBlock?.shouldLoadUrl(request.url.toString(), view?.getUrlSafe()) ?: true) {
|
||||||
} else {
|
super.shouldInterceptRequest(view, request)
|
||||||
emptyResponse()
|
} else {
|
||||||
}
|
emptyResponse()
|
||||||
|
}
|
||||||
|
|
||||||
private fun emptyResponse(): WebResourceResponse =
|
private fun emptyResponse(): WebResourceResponse =
|
||||||
WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(byteArrayOf()))
|
WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(byteArrayOf()))
|
||||||
|
|
||||||
|
@SuppressLint("WrongThread")
|
||||||
@AnyThread
|
@AnyThread
|
||||||
private fun WebView.getUrlSafe(): String? = if (Looper.myLooper() == Looper.getMainLooper()) {
|
private fun WebView.getUrlSafe(): String? = if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
url
|
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.AppShortcutManager
|
||||||
import org.koitharu.kotatsu.core.os.NetworkState
|
import org.koitharu.kotatsu.core.os.NetworkState
|
||||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
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.parser.favicon.FaviconFetcher
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
|
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
|
||||||
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
||||||
import org.koitharu.kotatsu.core.util.AcraScreenLogger
|
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.connectivityManager
|
||||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
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.MangaPageFetcher
|
||||||
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer
|
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer
|
||||||
import org.koitharu.kotatsu.local.data.CacheDir
|
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.LocalStorageChanges
|
||||||
|
import org.koitharu.kotatsu.local.data.PageCache
|
||||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||||
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
||||||
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
|
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
|
||||||
@@ -101,7 +104,7 @@ interface AppModule {
|
|||||||
fun provideCoil(
|
fun provideCoil(
|
||||||
@LocalizedAppContext context: Context,
|
@LocalizedAppContext context: Context,
|
||||||
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
|
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
|
||||||
mangaRepositoryFactory: MangaRepository.Factory,
|
faviconFetcherFactory: FaviconFetcher.Factory,
|
||||||
imageProxyInterceptor: ImageProxyInterceptor,
|
imageProxyInterceptor: ImageProxyInterceptor,
|
||||||
pageFetcherFactory: MangaPageFetcher.Factory,
|
pageFetcherFactory: MangaPageFetcher.Factory,
|
||||||
coverRestoreInterceptor: CoverRestoreInterceptor,
|
coverRestoreInterceptor: CoverRestoreInterceptor,
|
||||||
@@ -138,7 +141,7 @@ interface AppModule {
|
|||||||
add(SvgDecoder.Factory())
|
add(SvgDecoder.Factory())
|
||||||
add(CbzFetcher.Factory())
|
add(CbzFetcher.Factory())
|
||||||
add(AvifImageDecoder.Factory())
|
add(AvifImageDecoder.Factory())
|
||||||
add(FaviconFetcher.Factory(mangaRepositoryFactory))
|
add(faviconFetcherFactory)
|
||||||
add(MangaPageKeyer())
|
add(MangaPageKeyer())
|
||||||
add(pageFetcherFactory)
|
add(pageFetcherFactory)
|
||||||
add(imageProxyInterceptor)
|
add(imageProxyInterceptor)
|
||||||
@@ -195,5 +198,29 @@ interface AppModule {
|
|||||||
fun provideWorkManager(
|
fun provideWorkManager(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
): WorkManager = WorkManager.getInstance(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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import android.app.PendingIntent
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.BadParcelableException
|
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.app.PendingIntentCompat
|
import androidx.core.app.PendingIntentCompat
|
||||||
@@ -65,7 +64,7 @@ class ErrorReporterReceiver : BroadcastReceiver() {
|
|||||||
e: Throwable,
|
e: Throwable,
|
||||||
notificationId: Int,
|
notificationId: Int,
|
||||||
notificationTag: String?,
|
notificationTag: String?,
|
||||||
): PendingIntent? = try {
|
): PendingIntent? = runCatching {
|
||||||
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
||||||
intent.setAction(ACTION_REPORT)
|
intent.setAction(ACTION_REPORT)
|
||||||
intent.setData("err://${e.hashCode()}".toUri())
|
intent.setData("err://${e.hashCode()}".toUri())
|
||||||
@@ -73,9 +72,9 @@ class ErrorReporterReceiver : BroadcastReceiver() {
|
|||||||
intent.putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
intent.putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||||
intent.putExtra(EXTRA_NOTIFICATION_TAG, notificationTag)
|
intent.putExtra(EXTRA_NOTIFICATION_TAG, notificationTag)
|
||||||
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
|
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
|
||||||
} catch (e: BadParcelableException) {
|
}.onFailure { e ->
|
||||||
|
// probably cannot write exception as serializable
|
||||||
e.printStackTraceDebug()
|
e.printStackTraceDebug()
|
||||||
null
|
}.getOrNull()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import android.app.Notification
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.annotation.RequiresPermission
|
import androidx.annotation.RequiresPermission
|
||||||
@@ -14,7 +13,6 @@ import androidx.core.app.NotificationChannelCompat
|
|||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.app.PendingIntentCompat
|
import androidx.core.app.PendingIntentCompat
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.coroutineScope
|
import androidx.lifecycle.coroutineScope
|
||||||
import coil3.EventListener
|
import coil3.EventListener
|
||||||
@@ -26,6 +24,7 @@ import coil3.request.allowConversionToBitmap
|
|||||||
import coil3.request.allowHardware
|
import coil3.request.allowHardware
|
||||||
import coil3.request.lifecycle
|
import coil3.request.lifecycle
|
||||||
import coil3.size.Scale
|
import coil3.size.Scale
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
@@ -55,6 +54,7 @@ import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
|||||||
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToArray
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Provider
|
import javax.inject.Provider
|
||||||
@@ -70,22 +70,6 @@ class CaptchaHandler @Inject constructor(
|
|||||||
private val exceptionMap = MutableScatterMap<MangaSource, CloudFlareProtectedException>()
|
private val exceptionMap = MutableScatterMap<MangaSource, CloudFlareProtectedException>()
|
||||||
private val mutex = Mutex()
|
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun handle(exception: CloudFlareException): Boolean = handleException(exception.source, exception, true)
|
suspend fun handle(exception: CloudFlareException): Boolean = handleException(exception.source, exception, true)
|
||||||
|
|
||||||
suspend fun discard(source: MangaSource) {
|
suspend fun discard(source: MangaSource) {
|
||||||
@@ -121,14 +105,14 @@ class CaptchaHandler @Inject constructor(
|
|||||||
val dao = databaseProvider.get().getSourcesDao()
|
val dao = databaseProvider.get().getSourcesDao()
|
||||||
dao.setCfState(source.name, exception?.state ?: CloudFlareHelper.PROTECTION_NOT_DETECTED)
|
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)) {
|
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) {
|
if (removedException != null) {
|
||||||
NotificationManagerCompat.from(context).cancel(TAG, removedException.source.hashCode())
|
NotificationManagerCompat.from(context).cancel(TAG, removedException.source.hashCode())
|
||||||
}
|
}
|
||||||
@@ -169,6 +153,15 @@ class CaptchaHandler @Inject constructor(
|
|||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setSmallIcon(R.drawable.ic_bot)
|
.setSmallIcon(R.drawable.ic_bot)
|
||||||
.setGroup(GROUP_CAPTCHA)
|
.setGroup(GROUP_CAPTCHA)
|
||||||
|
.setContentIntent(
|
||||||
|
PendingIntentCompat.getActivities(
|
||||||
|
context, GROUP_NOTIFICATION_ID,
|
||||||
|
exceptions.mapToArray { e ->
|
||||||
|
AppRouter.cloudFlareResolveIntent(context, e)
|
||||||
|
},
|
||||||
|
0, false,
|
||||||
|
),
|
||||||
|
)
|
||||||
.setContentText(
|
.setContentText(
|
||||||
context.getString(
|
context.getString(
|
||||||
R.string.captcha_required_summary, context.getString(R.string.app_name),
|
R.string.captcha_required_summary, context.getString(R.string.app_name),
|
||||||
@@ -189,7 +182,6 @@ class CaptchaHandler @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun buildNotification(exception: CloudFlareProtectedException): Notification {
|
private suspend fun buildNotification(exception: CloudFlareProtectedException): Notification {
|
||||||
val intent = AppRouter.cloudFlareResolveIntent(context, exception)
|
val intent = AppRouter.cloudFlareResolveIntent(context, exception)
|
||||||
.setData(exception.url.toUri())
|
|
||||||
val discardIntent = Intent(ACTION_DISCARD)
|
val discardIntent = Intent(ACTION_DISCARD)
|
||||||
.putExtra(AppRouter.KEY_SOURCE, exception.source.name)
|
.putExtra(AppRouter.KEY_SOURCE, exception.source.name)
|
||||||
.setData("source://${exception.source.name}".toUri())
|
.setData("source://${exception.source.name}".toUri())
|
||||||
@@ -242,6 +234,7 @@ class CaptchaHandler @Inject constructor(
|
|||||||
.data(source.faviconUri())
|
.data(source.faviconUri())
|
||||||
.allowHardware(false)
|
.allowHardware(false)
|
||||||
.allowConversionToBitmap(true)
|
.allowConversionToBitmap(true)
|
||||||
|
.ignoreCaptchaErrors()
|
||||||
.mangaSourceExtra(source)
|
.mangaSourceExtra(source)
|
||||||
.size(context.resources.getNotificationIconSize())
|
.size(context.resources.getNotificationIconSize())
|
||||||
.scale(Scale.FILL)
|
.scale(Scale.FILL)
|
||||||
@@ -251,6 +244,20 @@ class CaptchaHandler @Inject constructor(
|
|||||||
it.printStackTraceDebug()
|
it.printStackTraceDebug()
|
||||||
}.getOrNull()
|
}.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 {
|
companion object {
|
||||||
|
|
||||||
fun ImageRequest.Builder.ignoreCaptchaErrors() = apply {
|
fun ImageRequest.Builder.ignoreCaptchaErrors() = apply {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.image
|
|||||||
import coil3.intercept.Interceptor
|
import coil3.intercept.Interceptor
|
||||||
import coil3.network.httpHeaders
|
import coil3.network.httpHeaders
|
||||||
import coil3.request.ImageResult
|
import coil3.request.ImageResult
|
||||||
|
import org.koitharu.kotatsu.core.model.unwrap
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceKey
|
import org.koitharu.kotatsu.core.util.ext.mangaSourceKey
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
@@ -10,7 +11,7 @@ import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
|||||||
class MangaSourceHeaderInterceptor : Interceptor {
|
class MangaSourceHeaderInterceptor : Interceptor {
|
||||||
|
|
||||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
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 request = chain.request
|
||||||
val newHeaders = request.httpHeaders.newBuilder()
|
val newHeaders = request.httpHeaders.newBuilder()
|
||||||
.set(CommonHeaders.MANGA_SOURCE, mangaSource.name)
|
.set(CommonHeaders.MANGA_SOURCE, mangaSource.name)
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ val MangaState.titleResId: Int
|
|||||||
MangaState.ABANDONED -> R.string.state_abandoned
|
MangaState.ABANDONED -> R.string.state_abandoned
|
||||||
MangaState.PAUSED -> R.string.state_paused
|
MangaState.PAUSED -> R.string.state_paused
|
||||||
MangaState.UPCOMING -> R.string.state_upcoming
|
MangaState.UPCOMING -> R.string.state_upcoming
|
||||||
|
MangaState.RESTRICTED -> R.string.unavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
@@ -65,6 +66,7 @@ val MangaState.iconResId: Int
|
|||||||
MangaState.ABANDONED -> R.drawable.ic_state_abandoned
|
MangaState.ABANDONED -> R.drawable.ic_state_abandoned
|
||||||
MangaState.PAUSED -> R.drawable.ic_action_pause
|
MangaState.PAUSED -> R.drawable.ic_action_pause
|
||||||
MangaState.UPCOMING -> materialR.drawable.ic_clock_black_24dp
|
MangaState.UPCOMING -> materialR.drawable.ic_clock_black_24dp
|
||||||
|
MangaState.RESTRICTED -> R.drawable.ic_disable
|
||||||
}
|
}
|
||||||
|
|
||||||
@get:StringRes
|
@get:StringRes
|
||||||
|
|||||||
@@ -281,6 +281,10 @@ class AppRouter private constructor(
|
|||||||
startActivity(sourcesSettingsIntent(contextOrNull() ?: return))
|
startActivity(sourcesSettingsIntent(contextOrNull() ?: return))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun openDiscordSettings() {
|
||||||
|
startActivity(discordSettingsIntent(contextOrNull() ?: return))
|
||||||
|
}
|
||||||
|
|
||||||
fun openReaderTapGridSettings() = startActivity(ReaderTapGridConfigActivity::class.java)
|
fun openReaderTapGridSettings() = startActivity(ReaderTapGridConfigActivity::class.java)
|
||||||
|
|
||||||
fun openScrobblerSettings(scrobbler: ScrobblerService) {
|
fun openScrobblerSettings(scrobbler: ScrobblerService) {
|
||||||
@@ -571,7 +575,7 @@ class AppRouter private constructor(
|
|||||||
/** Public utils **/
|
/** Public utils **/
|
||||||
|
|
||||||
fun isFilterSupported(): Boolean = when {
|
fun isFilterSupported(): Boolean = when {
|
||||||
fragment != null -> fragment.activity is FilterCoordinator.Owner
|
fragment != null -> FilterCoordinator.find(fragment) != null
|
||||||
activity != null -> activity is FilterCoordinator.Owner
|
activity != null -> activity is FilterCoordinator.Owner
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
@@ -741,6 +745,14 @@ class AppRouter private constructor(
|
|||||||
Intent(context, SettingsActivity::class.java)
|
Intent(context, SettingsActivity::class.java)
|
||||||
.setAction(ACTION_TRACKER)
|
.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) =
|
fun proxySettingsIntent(context: Context) =
|
||||||
Intent(context, SettingsActivity::class.java)
|
Intent(context, SettingsActivity::class.java)
|
||||||
.setAction(ACTION_PROXY)
|
.setAction(ACTION_PROXY)
|
||||||
@@ -800,6 +812,7 @@ class AppRouter private constructor(
|
|||||||
const val KEY_FILTER = "filter"
|
const val KEY_FILTER = "filter"
|
||||||
const val KEY_ID = "id"
|
const val KEY_ID = "id"
|
||||||
const val KEY_INDEX = "index"
|
const val KEY_INDEX = "index"
|
||||||
|
const val KEY_IS_BOTTOMTAB = "is_btab"
|
||||||
const val KEY_KIND = "kind"
|
const val KEY_KIND = "kind"
|
||||||
const val KEY_LIST_SECTION = "list_section"
|
const val KEY_LIST_SECTION = "list_section"
|
||||||
const val KEY_MANGA = "manga"
|
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_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
|
||||||
const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_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_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_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS"
|
||||||
const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER"
|
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 ACCOUNT_KEY = "account"
|
||||||
private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS"
|
private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package org.koitharu.kotatsu.core.network
|
package org.koitharu.kotatsu.core.network
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import dagger.Lazy
|
import dagger.Lazy
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
@@ -36,7 +35,8 @@ class CommonHeadersInterceptor @Inject constructor(
|
|||||||
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
|
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
|
||||||
} else {
|
} else {
|
||||||
if (BuildConfig.DEBUG && source == null) {
|
if (BuildConfig.DEBUG && source == null) {
|
||||||
Log.w("Http", "Request without source tag: ${request.url}")
|
IllegalArgumentException("Request without source tag: ${request.url}")
|
||||||
|
.printStackTrace()
|
||||||
}
|
}
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package org.koitharu.kotatsu.core.network.webview
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.annotation.MainThread
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
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.parsers.util.nullIfEmpty
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class WebViewExecutor @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
|
||||||
|
private var webViewCached: WeakReference<WebView>? = null
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
suspend fun evaluateJs(baseUrl: String?, script: String): String? = mutex.withLock {
|
||||||
|
withContext(Dispatchers.Main.immediate) {
|
||||||
|
val webView = obtainWebView()
|
||||||
|
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" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainThread
|
||||||
|
fun getDefaultUserAgent() = runCatching {
|
||||||
|
obtainWebView().settings.userAgentString.sanitizeHeaderValue().trim().nullIfEmpty()
|
||||||
|
}.onFailure { e ->
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
@MainThread
|
||||||
|
private fun obtainWebView(): WebView = webViewCached?.get() ?: WebView(context).also {
|
||||||
|
it.configureForParser(null)
|
||||||
|
webViewCached = WeakReference(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,7 +149,7 @@ class AppShortcutManager @Inject constructor(
|
|||||||
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
|
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
|
||||||
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
|
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
|
||||||
)
|
)
|
||||||
mangaRepository.storeManga(manga)
|
mangaRepository.storeManga(manga, replaceExisting = true)
|
||||||
val title = manga.title.ifEmpty {
|
val title = manga.title.ifEmpty {
|
||||||
manga.altTitles.firstOrNull()
|
manga.altTitles.firstOrNull()
|
||||||
}.ifNullOrEmpty {
|
}.ifNullOrEmpty {
|
||||||
@@ -173,6 +173,7 @@ class AppShortcutManager @Inject constructor(
|
|||||||
coil.execute(
|
coil.execute(
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
.data(source.faviconUri())
|
.data(source.faviconUri())
|
||||||
|
.mangaSourceExtra(source)
|
||||||
.size(iconSize)
|
.size(iconSize)
|
||||||
.scale(Scale.FIT)
|
.scale(Scale.FIT)
|
||||||
.build(),
|
.build(),
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import androidx.activity.result.contract.ActivityResultContract
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.core.app.ActivityOptionsCompat
|
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
|
// https://stackoverflow.com/questions/77555641/saf-no-activity-found-to-handle-intent-android-intent-action-open-document-tr
|
||||||
class OpenDocumentTreeHelper(
|
class OpenDocumentTreeHelper(
|
||||||
@@ -28,38 +27,42 @@ class OpenDocumentTreeHelper(
|
|||||||
callback,
|
callback,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val pickFileTreeLauncherQ = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
private val pickFileTreeLauncherPrimaryStorage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
activityResultCaller.registerForActivityResult(OpenDocumentTreeContractQ(flags), callback)
|
activityResultCaller.registerForActivityResult(OpenDocumentTreeContractPrimaryStorage(flags), callback)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
private val pickFileTreeLauncherLegacy = activityResultCaller.registerForActivityResult(
|
private val pickFileTreeLauncherDefault = activityResultCaller.registerForActivityResult(
|
||||||
contract = OpenDocumentTreeContractLegacy(flags),
|
contract = OpenDocumentTreeContractDefault(flags),
|
||||||
callback = callback,
|
callback = callback,
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun launch(input: Uri?, options: ActivityOptionsCompat?) {
|
override fun launch(input: Uri?, options: ActivityOptionsCompat?) {
|
||||||
if (pickFileTreeLauncherQ == null) {
|
|
||||||
pickFileTreeLauncherLegacy.launch(input, options)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
pickFileTreeLauncherQ.launch(input, options)
|
pickFileTreeLauncherDefault.launch(input, options)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTraceDebug()
|
if (pickFileTreeLauncherPrimaryStorage != null) {
|
||||||
pickFileTreeLauncherLegacy.launch(input, options)
|
try {
|
||||||
|
pickFileTreeLauncherPrimaryStorage.launch(input, options)
|
||||||
|
} catch (e2: Exception) {
|
||||||
|
e.addSuppressed(e2)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun unregister() {
|
override fun unregister() {
|
||||||
pickFileTreeLauncherQ?.unregister()
|
pickFileTreeLauncherPrimaryStorage?.unregister()
|
||||||
pickFileTreeLauncherLegacy.unregister()
|
pickFileTreeLauncherDefault.unregister()
|
||||||
}
|
}
|
||||||
|
|
||||||
override val contract: ActivityResultContract<Uri?, *>
|
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,
|
private val flags: Int,
|
||||||
) : ActivityResultContracts.OpenDocumentTree() {
|
) : ActivityResultContracts.OpenDocumentTree() {
|
||||||
|
|
||||||
@@ -71,9 +74,9 @@ class OpenDocumentTreeHelper(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
private class OpenDocumentTreeContractQ(
|
private class OpenDocumentTreeContractPrimaryStorage(
|
||||||
private val flags: Int,
|
private val flags: Int,
|
||||||
) : OpenDocumentTreeContractLegacy(flags) {
|
) : OpenDocumentTreeContractDefault(flags) {
|
||||||
|
|
||||||
override fun createIntent(context: Context, input: Uri?): Intent {
|
override fun createIntent(context: Context, input: Uri?): Intent {
|
||||||
val intent = (context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager)
|
val intent = (context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager)
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
|
|||||||
import org.koitharu.kotatsu.parsers.core.AbstractMangaParser
|
import org.koitharu.kotatsu.parsers.core.AbstractMangaParser
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
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
|
import java.util.EnumSet
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,14 +25,18 @@ class DummyParser(context: MangaLoaderContext) : AbstractMangaParser(context, Ma
|
|||||||
override val availableSortOrders: Set<SortOrder>
|
override val availableSortOrders: Set<SortOrder>
|
||||||
get() = EnumSet.allOf(SortOrder::class.java)
|
get() = EnumSet.allOf(SortOrder::class.java)
|
||||||
|
|
||||||
override val searchQueryCapabilities: MangaSearchQueryCapabilities
|
override val filterCapabilities: MangaListFilterCapabilities
|
||||||
get() = MangaSearchQueryCapabilities()
|
get() = MangaListFilterCapabilities()
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||||
|
|
||||||
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
|
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
|
||||||
|
|
||||||
override suspend fun getList(query: MangaSearchQuery): List<Manga> = stub(null)
|
override suspend fun getList(
|
||||||
|
offset: Int,
|
||||||
|
order: SortOrder,
|
||||||
|
filter: MangaListFilter
|
||||||
|
): List<Manga> = stub(null)
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class MangaDataRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) {
|
suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) {
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
storeManga(manga)
|
storeManga(manga, replaceExisting = false)
|
||||||
val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id)
|
val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id)
|
||||||
db.getPreferencesDao().upsert(entity.copy(mode = mode.id))
|
db.getPreferencesDao().upsert(entity.copy(mode = mode.id))
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ class MangaDataRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun saveColorFilter(manga: Manga, colorFilter: ReaderColorFilter?) {
|
suspend fun saveColorFilter(manga: Manga, colorFilter: ReaderColorFilter?) {
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
storeManga(manga)
|
storeManga(manga, replaceExisting = false)
|
||||||
val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id)
|
val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id)
|
||||||
db.getPreferencesDao().upsert(
|
db.getPreferencesDao().upsert(
|
||||||
entity.copy(
|
entity.copy(
|
||||||
@@ -87,10 +87,11 @@ class MangaDataRepository @Inject constructor(
|
|||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setOverride(mangaId: Long, override: MangaOverride?) {
|
suspend fun setOverride(manga: Manga, override: MangaOverride?) {
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
|
storeManga(manga, replaceExisting = false)
|
||||||
val dao = db.getPreferencesDao()
|
val dao = db.getPreferencesDao()
|
||||||
val entity = dao.find(mangaId) ?: newEntity(mangaId)
|
val entity = dao.find(manga.id) ?: newEntity(manga.id)
|
||||||
dao.upsert(
|
dao.upsert(
|
||||||
entity.copy(
|
entity.copy(
|
||||||
titleOverride = override?.title?.nullIfEmpty(),
|
titleOverride = override?.title?.nullIfEmpty(),
|
||||||
@@ -127,7 +128,10 @@ class MangaDataRepository @Inject constructor(
|
|||||||
else -> null
|
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 {
|
db.withTransaction {
|
||||||
// avoid storing local manga if remote one is already stored
|
// avoid storing local manga if remote one is already stored
|
||||||
val existing = if (manga.isLocal) {
|
val existing = if (manga.isLocal) {
|
||||||
@@ -185,7 +189,7 @@ class MangaDataRepository @Inject constructor(
|
|||||||
emitInitialState = emitInitialState,
|
emitInitialState = emitInitialState,
|
||||||
)
|
)
|
||||||
|
|
||||||
private suspend fun Manga.withCachedChaptersIfNeeded(flag: Boolean): Manga = if (flag && chapters.isNullOrEmpty()) {
|
private suspend fun Manga.withCachedChaptersIfNeeded(flag: Boolean): Manga = if (flag && !isLocal && chapters.isNullOrEmpty()) {
|
||||||
val cachedChapters = db.getChaptersDao().findAll(id)
|
val cachedChapters = db.getChaptersDao().findAll(id)
|
||||||
if (cachedChapters.isEmpty()) {
|
if (cachedChapters.isEmpty()) {
|
||||||
this
|
this
|
||||||
|
|||||||
@@ -3,15 +3,10 @@ package org.koitharu.kotatsu.core.parser
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.webkit.WebView
|
|
||||||
import androidx.annotation.MainThread
|
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@@ -22,11 +17,8 @@ import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
|||||||
import org.koitharu.kotatsu.core.image.BitmapDecoderCompat
|
import org.koitharu.kotatsu.core.image.BitmapDecoderCompat
|
||||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
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.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.toList
|
||||||
import org.koitharu.kotatsu.core.util.ext.toMimeType
|
import org.koitharu.kotatsu.core.util.ext.toMimeType
|
||||||
import org.koitharu.kotatsu.core.util.ext.use
|
import org.koitharu.kotatsu.core.util.ext.use
|
||||||
@@ -37,25 +29,21 @@ import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
|||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||||
import org.koitharu.kotatsu.parsers.util.map
|
import org.koitharu.kotatsu.parsers.util.map
|
||||||
import java.lang.ref.WeakReference
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.coroutines.EmptyCoroutineContext
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
import kotlin.coroutines.resume
|
|
||||||
import kotlin.coroutines.suspendCoroutine
|
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class MangaLoaderContextImpl @Inject constructor(
|
class MangaLoaderContextImpl @Inject constructor(
|
||||||
@MangaHttpClient override val httpClient: OkHttpClient,
|
@MangaHttpClient override val httpClient: OkHttpClient,
|
||||||
override val cookieJar: MutableCookieJar,
|
override val cookieJar: MutableCookieJar,
|
||||||
@ApplicationContext private val androidContext: Context,
|
@ApplicationContext private val androidContext: Context,
|
||||||
|
private val webViewExecutor: WebViewExecutor,
|
||||||
) : MangaLoaderContext() {
|
) : MangaLoaderContext() {
|
||||||
|
|
||||||
private var webViewCached: WeakReference<WebView>? = null
|
|
||||||
private val webViewUserAgent by lazy { obtainWebViewUserAgent() }
|
private val webViewUserAgent by lazy { obtainWebViewUserAgent() }
|
||||||
private val jsMutex = Mutex()
|
|
||||||
private val jsTimeout = TimeUnit.SECONDS.toMillis(4)
|
private val jsTimeout = TimeUnit.SECONDS.toMillis(4)
|
||||||
|
|
||||||
@Deprecated("Provide a base url")
|
@Deprecated("Provide a base url")
|
||||||
@@ -63,22 +51,7 @@ class MangaLoaderContextImpl @Inject constructor(
|
|||||||
override suspend fun evaluateJs(script: String): String? = evaluateJs("", script)
|
override suspend fun evaluateJs(script: String): String? = evaluateJs("", script)
|
||||||
|
|
||||||
override suspend fun evaluateJs(baseUrl: String, script: String): String? = withTimeout(jsTimeout) {
|
override suspend fun evaluateJs(baseUrl: String, script: String): String? = withTimeout(jsTimeout) {
|
||||||
jsMutex.withLock {
|
webViewExecutor.evaluateJs(baseUrl, script)
|
||||||
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" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getDefaultUserAgent(): String = webViewUserAgent
|
override fun getDefaultUserAgent(): String = webViewUserAgent
|
||||||
@@ -119,27 +92,14 @@ class MangaLoaderContextImpl @Inject constructor(
|
|||||||
|
|
||||||
override fun createBitmap(width: Int, height: Int): Bitmap = BitmapWrapper.create(width, height)
|
override fun createBitmap(width: Int, height: Int): Bitmap = BitmapWrapper.create(width, height)
|
||||||
|
|
||||||
@MainThread
|
|
||||||
private fun obtainWebView(): WebView = webViewCached?.get() ?: WebView(androidContext).also {
|
|
||||||
it.configureForParser(null)
|
|
||||||
webViewCached = WeakReference(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun obtainWebViewUserAgent(): String {
|
private fun obtainWebViewUserAgent(): String {
|
||||||
val mainDispatcher = Dispatchers.Main.immediate
|
val mainDispatcher = Dispatchers.Main.immediate
|
||||||
return if (!mainDispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
|
return if (!mainDispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
|
||||||
obtainWebViewUserAgentImpl()
|
webViewExecutor.getDefaultUserAgent()
|
||||||
} else {
|
} else {
|
||||||
runBlocking(mainDispatcher) {
|
runBlocking(mainDispatcher) {
|
||||||
obtainWebViewUserAgentImpl()
|
webViewExecutor.getDefaultUserAgent()
|
||||||
}
|
}
|
||||||
}
|
} ?: UserAgents.FIREFOX_MOBILE
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainThread
|
|
||||||
private fun obtainWebViewUserAgentImpl() = runCatching {
|
|
||||||
obtainWebView().settings.userAgentString.sanitizeHeaderValue()
|
|
||||||
}.onFailure { e ->
|
|
||||||
e.printStackTraceDebug()
|
|
||||||
}.getOrDefault(UserAgents.FIREFOX_MOBILE)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ class ExternalPluginContentSource(
|
|||||||
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
|
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
|
||||||
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
|
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
|
||||||
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
|
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
|
||||||
|
if (!filter.author.isNullOrEmpty()) {
|
||||||
|
uri.appendQueryParameter("author", filter.author)
|
||||||
|
}
|
||||||
if (!filter.query.isNullOrEmpty()) {
|
if (!filter.query.isNullOrEmpty()) {
|
||||||
uri.appendQueryParameter("query", filter.query)
|
uri.appendQueryParameter("query", filter.query)
|
||||||
}
|
}
|
||||||
@@ -196,6 +199,7 @@ class ExternalPluginContentSource(
|
|||||||
isYearSupported = cursor.getBooleanOrDefault(COLUMN_YEAR, false),
|
isYearSupported = cursor.getBooleanOrDefault(COLUMN_YEAR, false),
|
||||||
isYearRangeSupported = cursor.getBooleanOrDefault(COLUMN_YEAR_RANGE, false),
|
isYearRangeSupported = cursor.getBooleanOrDefault(COLUMN_YEAR_RANGE, false),
|
||||||
isOriginalLocaleSupported = cursor.getBooleanOrDefault(COLUMN_ORIGINAL_LOCALE, false),
|
isOriginalLocaleSupported = cursor.getBooleanOrDefault(COLUMN_ORIGINAL_LOCALE, false),
|
||||||
|
isAuthorSearchSupported = cursor.getBooleanOrDefault(COLUMN_AUTHOR, false),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -10,15 +10,20 @@ import coil3.ColorImage
|
|||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.asImage
|
import coil3.asImage
|
||||||
import coil3.decode.DataSource
|
import coil3.decode.DataSource
|
||||||
|
import coil3.decode.ImageSource
|
||||||
import coil3.fetch.FetchResult
|
import coil3.fetch.FetchResult
|
||||||
import coil3.fetch.Fetcher
|
import coil3.fetch.Fetcher
|
||||||
import coil3.fetch.ImageFetchResult
|
import coil3.fetch.ImageFetchResult
|
||||||
|
import coil3.fetch.SourceFetchResult
|
||||||
import coil3.request.Options
|
import coil3.request.Options
|
||||||
import coil3.size.pxOrElse
|
import coil3.size.pxOrElse
|
||||||
import coil3.toAndroidUri
|
import coil3.toAndroidUri
|
||||||
|
import coil3.toBitmap
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import okio.FileSystem
|
||||||
import okio.IOException
|
import okio.IOException
|
||||||
|
import okio.Path.Companion.toOkioPath
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
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.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
|
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.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.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 kotlin.coroutines.coroutineContext
|
||||||
import coil3.Uri as CoilUri
|
import coil3.Uri as CoilUri
|
||||||
|
|
||||||
@@ -36,6 +49,7 @@ class FaviconFetcher(
|
|||||||
private val options: Options,
|
private val options: Options,
|
||||||
private val imageLoader: ImageLoader,
|
private val imageLoader: ImageLoader,
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
private val localStorageCache: LocalStorageCache,
|
||||||
) : Fetcher {
|
) : Fetcher {
|
||||||
|
|
||||||
override suspend fun fetch(): FetchResult? {
|
override suspend fun fetch(): FetchResult? {
|
||||||
@@ -61,6 +75,16 @@ class FaviconFetcher(
|
|||||||
options.size.width.pxOrElse { FALLBACK_SIZE },
|
options.size.width.pxOrElse { FALLBACK_SIZE },
|
||||||
options.size.height.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 favicons = repository.getFavicons()
|
||||||
var lastError: Exception? = null
|
var lastError: Exception? = null
|
||||||
while (favicons.isNotEmpty()) {
|
while (favicons.isNotEmpty()) {
|
||||||
@@ -69,7 +93,11 @@ class FaviconFetcher(
|
|||||||
try {
|
try {
|
||||||
val result = imageLoader.fetch(icon.url, options)
|
val result = imageLoader.fetch(icon.url, options)
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
return result
|
return if (options.diskCachePolicy.writeEnabled) {
|
||||||
|
writeToCache(cacheKey, result)
|
||||||
|
} else {
|
||||||
|
result
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
favicons -= icon
|
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,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
@FaviconCache private val faviconCache: LocalStorageCache,
|
||||||
) : Fetcher.Factory<CoilUri> {
|
) : Fetcher.Factory<CoilUri> {
|
||||||
|
|
||||||
override fun create(
|
override fun create(
|
||||||
@@ -106,7 +165,7 @@ class FaviconFetcher(
|
|||||||
options: Options,
|
options: Options,
|
||||||
imageLoader: ImageLoader
|
imageLoader: ImageLoader
|
||||||
): Fetcher? = if (data.scheme == URI_SCHEME_FAVICON) {
|
): Fetcher? = if (data.scheme == URI_SCHEME_FAVICON) {
|
||||||
FaviconFetcher(data.toAndroidUri(), options, imageLoader, mangaRepositoryFactory)
|
FaviconFetcher(data.toAndroidUri(), options, imageLoader, mangaRepositoryFactory, faviconCache)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,17 @@ import androidx.core.os.LocaleListCompat
|
|||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
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.R
|
||||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||||
import org.koitharu.kotatsu.core.network.DoHProvider
|
import org.koitharu.kotatsu.core.network.DoHProvider
|
||||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||||
import org.koitharu.kotatsu.core.util.ext.getEnumValue
|
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.putAll
|
||||||
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
||||||
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
|
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
|
||||||
@@ -82,6 +87,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
val isNavBarPinned: Boolean
|
val isNavBarPinned: Boolean
|
||||||
get() = prefs.getBoolean(KEY_NAV_PINNED, false)
|
get() = prefs.getBoolean(KEY_NAV_PINNED, false)
|
||||||
|
|
||||||
|
val isMainFabEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_MAIN_FAB, true)
|
||||||
|
|
||||||
var gridSize: Int
|
var gridSize: Int
|
||||||
get() = prefs.getInt(KEY_GRID_SIZE, 100)
|
get() = prefs.getInt(KEY_GRID_SIZE, 100)
|
||||||
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
|
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
|
||||||
@@ -494,6 +502,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
|
val isPagesPreloadEnabled: Boolean
|
||||||
get() {
|
get() {
|
||||||
if (isBackgroundNetworkRestricted()) {
|
if (isBackgroundNetworkRestricted()) {
|
||||||
@@ -509,6 +521,16 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
val is32BitColorsEnabled: Boolean
|
val is32BitColorsEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_32BIT_COLOR, false)
|
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
|
val isPeriodicalBackupEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false)
|
get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false)
|
||||||
|
|
||||||
@@ -598,7 +620,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
prefs.unregisterOnSharedPreferenceChangeListener(listener)
|
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
|
fun getAllValues(): Map<String, *> = prefs.all
|
||||||
|
|
||||||
@@ -728,6 +755,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_TIPS_CLOSED = "tips_closed"
|
const val KEY_TIPS_CLOSED = "tips_closed"
|
||||||
const val KEY_SSL_BYPASS = "ssl_bypass"
|
const val KEY_SSL_BYPASS = "ssl_bypass"
|
||||||
const val KEY_READER_AUTOSCROLL_SPEED = "as_speed"
|
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_MIRROR_SWITCHING = "mirror_switching"
|
||||||
const val KEY_PROXY = "proxy"
|
const val KEY_PROXY = "proxy"
|
||||||
const val KEY_PROXY_TYPE = "proxy_type_2"
|
const val KEY_PROXY_TYPE = "proxy_type_2"
|
||||||
@@ -743,6 +771,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_NAV_MAIN = "nav_main"
|
const val KEY_NAV_MAIN = "nav_main"
|
||||||
const val KEY_NAV_LABELS = "nav_labels"
|
const val KEY_NAV_LABELS = "nav_labels"
|
||||||
const val KEY_NAV_PINNED = "nav_pinned"
|
const val KEY_NAV_PINNED = "nav_pinned"
|
||||||
|
const val KEY_MAIN_FAB = "main_fab"
|
||||||
const val KEY_32BIT_COLOR = "enhanced_colors"
|
const val KEY_32BIT_COLOR = "enhanced_colors"
|
||||||
const val KEY_SOURCES_ORDER = "sources_sort_order"
|
const val KEY_SOURCES_ORDER = "sources_sort_order"
|
||||||
const val KEY_SOURCES_CATALOG = "sources_catalog"
|
const val KEY_SOURCES_CATALOG = "sources_catalog"
|
||||||
@@ -768,6 +797,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_BACKUP_TG_CHAT = "backup_periodic_tg_chat_id"
|
const val KEY_BACKUP_TG_CHAT = "backup_periodic_tg_chat_id"
|
||||||
const val KEY_MANGA_LIST_BADGES = "manga_list_badges"
|
const val KEY_MANGA_LIST_BADGES = "manga_list_badges"
|
||||||
const val KEY_TAGS_WARNINGS = "tags_warnings"
|
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
|
// keys for non-persistent preferences
|
||||||
const val KEY_APP_VERSION = "app_version"
|
const val KEY_APP_VERSION = "app_version"
|
||||||
@@ -780,6 +812,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_PROXY_TEST = "proxy_test"
|
const val KEY_PROXY_TEST = "proxy_test"
|
||||||
const val KEY_OPEN_BROWSER = "open_browser"
|
const val KEY_OPEN_BROWSER = "open_browser"
|
||||||
const val KEY_HANDLE_LINKS = "handle_links"
|
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_OPEN = "backup_periodic_tg_open"
|
||||||
const val KEY_BACKUP_TG_TEST = "backup_periodic_tg_test"
|
const val KEY_BACKUP_TG_TEST = "backup_periodic_tg_test"
|
||||||
const val KEY_CLEAR_MANGA_DATA = "manga_data_clear"
|
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 {
|
fun <T> AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow {
|
||||||
var lastValue: T = valueProducer()
|
var lastValue: T = valueProducer()
|
||||||
emit(lastValue)
|
emit(lastValue)
|
||||||
observe().collect {
|
observeChanges().collect {
|
||||||
if (it == key) {
|
if (it == key) {
|
||||||
val value = valueProducer()
|
val value = valueProducer()
|
||||||
if (value != lastValue) {
|
if (value != lastValue) {
|
||||||
@@ -25,7 +25,7 @@ fun <T> AppSettings.observeAsStateFlow(
|
|||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
key: String,
|
key: String,
|
||||||
valueProducer: AppSettings.() -> T,
|
valueProducer: AppSettings.() -> T,
|
||||||
): StateFlow<T> = observe().transform {
|
): StateFlow<T> = observeChanges().transform {
|
||||||
if (it == key) {
|
if (it == key) {
|
||||||
emit(valueProducer())
|
emit(valueProducer())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,8 +66,9 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
const val KEY_SORT_ORDER = "sort_order"
|
const val KEY_DOMAIN = "domain"
|
||||||
const val KEY_SLOWDOWN = "slowdown"
|
|
||||||
const val KEY_NO_CAPTCHA = "no_captcha"
|
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
|
package org.koitharu.kotatsu.core.ui
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.OnApplyWindowInsetsListener
|
import androidx.core.view.OnApplyWindowInsetsListener
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
@@ -86,6 +88,12 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
|||||||
(activity as? SettingsActivity)?.setSectionTitle(title)
|
(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) {
|
private fun focusPreference(key: String) {
|
||||||
val pref = findPreference<Preference>(key)
|
val pref = findPreference<Preference>(key)
|
||||||
if (pref == null) {
|
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.MutableEventFlow
|
||||||
import org.koitharu.kotatsu.core.util.ext.call
|
import org.koitharu.kotatsu.core.util.ext.call
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import kotlin.coroutines.AbstractCoroutineContextElement
|
||||||
import kotlin.coroutines.CoroutineContext
|
import kotlin.coroutines.CoroutineContext
|
||||||
import kotlin.coroutines.EmptyCoroutineContext
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
|
|
||||||
@@ -43,13 +44,13 @@ abstract class BaseViewModel : ViewModel() {
|
|||||||
context: CoroutineContext = EmptyCoroutineContext,
|
context: CoroutineContext = EmptyCoroutineContext,
|
||||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||||
block: suspend CoroutineScope.() -> Unit
|
block: suspend CoroutineScope.() -> Unit
|
||||||
): Job = viewModelScope.launch(context + createErrorHandler(), start, block)
|
): Job = viewModelScope.launch(context.withDefaultExceptionHandler(), start, block)
|
||||||
|
|
||||||
protected fun launchLoadingJob(
|
protected fun launchLoadingJob(
|
||||||
context: CoroutineContext = EmptyCoroutineContext,
|
context: CoroutineContext = EmptyCoroutineContext,
|
||||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||||
block: suspend CoroutineScope.() -> Unit
|
block: suspend CoroutineScope.() -> Unit
|
||||||
): Job = viewModelScope.launch(context + createErrorHandler(), start) {
|
): Job = viewModelScope.launch(context.withDefaultExceptionHandler(), start) {
|
||||||
loadingCounter.increment()
|
loadingCounter.increment()
|
||||||
try {
|
try {
|
||||||
block()
|
block()
|
||||||
@@ -80,10 +81,28 @@ abstract class BaseViewModel : ViewModel() {
|
|||||||
|
|
||||||
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
|
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
|
||||||
|
|
||||||
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
|
private fun CoroutineContext.withDefaultExceptionHandler() =
|
||||||
throwable.printStackTraceDebug()
|
if (this[CoroutineExceptionHandler.Key] is EventExceptionHandler) {
|
||||||
if (throwable !is CancellationException) {
|
this
|
||||||
errorEvent.call(throwable)
|
} 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class RememberCheckListener(
|
|||||||
var isChecked: Boolean = initialValue
|
var isChecked: Boolean = initialValue
|
||||||
private set
|
private set
|
||||||
|
|
||||||
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
|
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
|
||||||
this.isChecked = isChecked
|
this.isChecked = isChecked
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +1,38 @@
|
|||||||
package org.koitharu.kotatsu.core.util
|
package org.koitharu.kotatsu.core.util
|
||||||
|
|
||||||
import androidx.collection.ArrayMap
|
import androidx.annotation.VisibleForTesting
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlin.contracts.InvocationKind
|
import kotlin.contracts.InvocationKind
|
||||||
import kotlin.contracts.contract
|
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
|
@VisibleForTesting
|
||||||
get() = delegates.size
|
val size: Int
|
||||||
|
get() = delegates.count { it.value.isLocked }
|
||||||
|
|
||||||
override fun contains(element: T): Boolean = synchronized(delegates) {
|
fun isNotEmpty() = delegates.any { it.value.isLocked }
|
||||||
delegates.containsKey(element)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun containsAll(elements: Collection<T>): Boolean = synchronized(delegates) {
|
fun isEmpty() = delegates.none { it.value.isLocked }
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun lock(element: T) {
|
suspend fun lock(element: T) {
|
||||||
val mutex = synchronized(delegates) {
|
val mutex = delegates.computeIfAbsent(element) { Mutex() }
|
||||||
delegates.getOrPut(element, ::Mutex)
|
|
||||||
}
|
|
||||||
mutex.lock()
|
mutex.lock()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unlock(element: T) {
|
fun unlock(element: T) {
|
||||||
synchronized(delegates) {
|
delegates[element]?.unlock()
|
||||||
delegates.remove(element)?.unlock()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend inline fun <R> withLock(element: T, block: () -> R): R {
|
suspend inline fun <R> withLock(element: T, block: () -> R): R {
|
||||||
contract {
|
contract {
|
||||||
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
||||||
}
|
}
|
||||||
|
lock(element)
|
||||||
return try {
|
return try {
|
||||||
lock(element)
|
|
||||||
block()
|
block()
|
||||||
} finally {
|
} finally {
|
||||||
unlock(element)
|
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?>.firstNotNull(): T = checkNotNull(first { x -> x != null })
|
||||||
|
|
||||||
suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { 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
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
@@ -12,6 +15,7 @@ import okio.FileSystem
|
|||||||
import okio.IOException
|
import okio.IOException
|
||||||
import okio.Path
|
import okio.Path
|
||||||
import okio.Source
|
import okio.Source
|
||||||
|
import okio.source
|
||||||
import org.koitharu.kotatsu.core.util.CancellableSource
|
import org.koitharu.kotatsu.core.util.CancellableSource
|
||||||
import org.koitharu.kotatsu.core.util.progress.ProgressResponseBody
|
import org.koitharu.kotatsu.core.util.progress.ProgressResponseBody
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
@@ -57,3 +61,8 @@ fun FileSystem.isRegularFile(path: Path) = try {
|
|||||||
} catch (_: IOException) {
|
} catch (_: IOException) {
|
||||||
false
|
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)
|
putString(key, value?.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
|
fun SharedPreferences.observeChanges(): Flow<String?> = callbackFlow {
|
||||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||||
trySendBlocking(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 {
|
fun <T> SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow<T> = flow {
|
||||||
emit(valueProducer())
|
emit(valueProducer())
|
||||||
observe().collect { upstreamKey ->
|
observeChanges().collect { upstreamKey ->
|
||||||
if (upstreamKey == key) {
|
if (upstreamKey == key) {
|
||||||
emit(valueProducer())
|
emit(valueProducer())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.database.sqlite.SQLiteFullException
|
|||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import coil3.network.HttpException
|
import coil3.network.HttpException
|
||||||
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.internal.http2.StreamResetException
|
import okhttp3.internal.http2.StreamResetException
|
||||||
import okio.FileNotFoundException
|
import okio.FileNotFoundException
|
||||||
@@ -52,6 +53,7 @@ import java.net.SocketException
|
|||||||
import java.net.SocketTimeoutException
|
import java.net.SocketTimeoutException
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
import java.util.Locale
|
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_NO_SPACE_LEFT = "No space left on device"
|
||||||
private const val MSG_CONNECTION_RESET = "Connection reset"
|
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)
|
?: resources.getString(R.string.error_occurred)
|
||||||
|
|
||||||
private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) {
|
private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) {
|
||||||
|
is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message
|
||||||
is CaughtException -> cause.getDisplayMessageOrNull(resources)
|
is CaughtException -> cause.getDisplayMessageOrNull(resources)
|
||||||
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
|
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
|
||||||
is ScrobblerAuthRequiredException -> resources.getString(
|
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 SQLiteFullException -> resources.getString(R.string.error_no_space_left)
|
||||||
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||||
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
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) {
|
private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) {
|
||||||
404 -> resources.getString(R.string.not_found_404)
|
HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404)
|
||||||
403 -> resources.getString(R.string.access_denied_403)
|
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)
|
in 500..599 -> resources.getString(R.string.server_error, statusCode)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,16 @@ fun View.setOnContextClickListenerCompat(listener: OnContextClickListenerCompat)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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?
|
val Toolbar.menuView: ActionMenuView?
|
||||||
get() {
|
get() {
|
||||||
menu // to call ensureMenu()
|
menu // to call ensureMenu()
|
||||||
@@ -201,7 +211,7 @@ fun Chip.setProgressIcon() {
|
|||||||
fun View.setContentDescriptionAndTooltip(@StringRes resId: Int) {
|
fun View.setContentDescriptionAndTooltip(@StringRes resId: Int) {
|
||||||
val text = resources.getString(resId)
|
val text = resources.getString(resId)
|
||||||
contentDescription = text
|
contentDescription = text
|
||||||
TooltipCompat.setTooltipText(this, text)
|
setTooltipCompat(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun View.getWindowBounds(): Rect {
|
fun View.getWindowBounds(): Rect {
|
||||||
|
|||||||
@@ -31,13 +31,12 @@ data class MangaDetails(
|
|||||||
val id: Long
|
val id: Long
|
||||||
get() = manga.id
|
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 allChapters: List<MangaChapter> by lazy { mergeChapters() }
|
||||||
|
|
||||||
|
val chapters: Map<String?, List<MangaChapter>> by lazy {
|
||||||
|
allChapters.groupBy { it.branch }
|
||||||
|
}
|
||||||
|
|
||||||
val isLocal
|
val isLocal
|
||||||
get() = manga.isLocal
|
get() = manga.isLocal
|
||||||
|
|
||||||
@@ -51,7 +50,22 @@ data class MangaDetails(
|
|||||||
.ifNullOrEmpty { localManga?.manga?.coverUrl }
|
.ifNullOrEmpty { localManga?.manga?.coverUrl }
|
||||||
?.nullIfEmpty()
|
?.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? {
|
fun getLocale(): Locale? {
|
||||||
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
|
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package org.koitharu.kotatsu.details.domain
|
package org.koitharu.kotatsu.details.domain
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||||
import org.koitharu.kotatsu.core.model.isNsfw
|
import org.koitharu.kotatsu.core.model.isNsfw
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
|||||||
@@ -9,30 +9,31 @@ import androidx.core.text.parseAsHtml
|
|||||||
import coil3.request.CachePolicy
|
import coil3.request.CachePolicy
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import okio.IOException
|
|
||||||
import org.koitharu.kotatsu.core.model.isLocal
|
import org.koitharu.kotatsu.core.model.isLocal
|
||||||
import org.koitharu.kotatsu.core.nav.MangaIntent
|
import org.koitharu.kotatsu.core.nav.MangaIntent
|
||||||
import org.koitharu.kotatsu.core.os.NetworkState
|
import org.koitharu.kotatsu.core.os.NetworkState
|
||||||
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
|
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.util.ext.peek
|
import org.koitharu.kotatsu.core.ui.model.MangaOverride
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.sanitize
|
import org.koitharu.kotatsu.core.util.ext.sanitize
|
||||||
import org.koitharu.kotatsu.details.data.MangaDetails
|
import org.koitharu.kotatsu.details.data.MangaDetails
|
||||||
import org.koitharu.kotatsu.explore.domain.RecoverMangaUseCase
|
import org.koitharu.kotatsu.explore.domain.RecoverMangaUseCase
|
||||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
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.exception.NotFoundException
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
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.recoverNotNull
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import org.koitharu.kotatsu.tracker.domain.CheckNewChaptersUseCase
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Provider
|
|
||||||
|
|
||||||
class DetailsLoadUseCase @Inject constructor(
|
class DetailsLoadUseCase @Inject constructor(
|
||||||
private val mangaDataRepository: MangaDataRepository,
|
private val mangaDataRepository: MangaDataRepository,
|
||||||
@@ -40,84 +41,116 @@ class DetailsLoadUseCase @Inject constructor(
|
|||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
private val recoverUseCase: RecoverMangaUseCase,
|
private val recoverUseCase: RecoverMangaUseCase,
|
||||||
private val imageGetter: Html.ImageGetter,
|
private val imageGetter: Html.ImageGetter,
|
||||||
private val newChaptersUseCaseProvider: Provider<CheckNewChaptersUseCase>,
|
|
||||||
private val networkState: NetworkState,
|
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)) {
|
val manga = requireNotNull(mangaDataRepository.resolveIntent(intent, withChapters = true)) {
|
||||||
"Cannot resolve intent $intent"
|
"Cannot resolve intent $intent"
|
||||||
}
|
}
|
||||||
val override = mangaDataRepository.getOverride(manga.id)
|
val override = mangaDataRepository.getOverride(manga.id)
|
||||||
send(
|
emit(
|
||||||
MangaDetails(
|
MangaDetails(
|
||||||
manga = manga,
|
manga = manga,
|
||||||
localManga = null,
|
localManga = null,
|
||||||
override = override,
|
override = override,
|
||||||
description = null,
|
description = manga.description?.parseAsHtml(withImages = false),
|
||||||
isLoaded = false,
|
isLoaded = false,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val local = if (!manga.isLocal) {
|
if (manga.isLocal) {
|
||||||
async {
|
loadLocal(manga, override, force)
|
||||||
localMangaRepository.findSavedManga(manga)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
null
|
loadRemote(manga, override, force)
|
||||||
}
|
}
|
||||||
if (!force && networkState.isOfflineOrRestricted()) {
|
}.distinctUntilChanged()
|
||||||
// try to avoid loading if has saved manga
|
.flowOn(Dispatchers.Default)
|
||||||
val localManga = local?.await()
|
|
||||||
if (manga.isLocal || localManga != null) {
|
/**
|
||||||
send(
|
* Load local manga + try to load the linked remote one if network is not restricted
|
||||||
MangaDetails(
|
* Suppress any network errors
|
||||||
manga = manga,
|
*/
|
||||||
localManga = localManga,
|
private suspend fun FlowCollector<MangaDetails>.loadLocal(manga: Manga, override: MangaOverride?, force: Boolean) {
|
||||||
override = override,
|
val skipNetworkLoad = !force && networkState.isOfflineOrRestricted()
|
||||||
description = manga.description?.parseAsHtml(withImages = true)?.trim(),
|
val localDetails = localMangaRepository.getDetails(manga)
|
||||||
isLoaded = true,
|
emit(
|
||||||
),
|
MangaDetails(
|
||||||
)
|
manga = localDetails,
|
||||||
return@channelFlow
|
localManga = null,
|
||||||
}
|
override = override,
|
||||||
|
description = localDetails.description?.parseAsHtml(withImages = false),
|
||||||
|
isLoaded = skipNetworkLoad,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (skipNetworkLoad) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
try {
|
val remoteManga = localMangaRepository.getRemoteManga(manga)
|
||||||
val details = getDetails(manga, force)
|
if (remoteManga == null) {
|
||||||
launch { mangaDataRepository.updateChapters(details) }
|
emit(
|
||||||
launch { updateTracker(details) }
|
|
||||||
send(
|
|
||||||
MangaDetails(
|
MangaDetails(
|
||||||
manga = details,
|
manga = localDetails,
|
||||||
localManga = local?.peek(),
|
localManga = null,
|
||||||
override = override,
|
override = override,
|
||||||
description = details.description?.parseAsHtml(withImages = false)?.trim(),
|
description = localDetails.description?.parseAsHtml(withImages = true),
|
||||||
isLoaded = false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
send(
|
|
||||||
MangaDetails(
|
|
||||||
manga = details,
|
|
||||||
localManga = local?.await(),
|
|
||||||
override = override,
|
|
||||||
description = details.description?.parseAsHtml(withImages = true)?.trim(),
|
|
||||||
isLoaded = true,
|
isLoaded = true,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} catch (e: IOException) {
|
} else {
|
||||||
local?.await()?.manga?.also { localManga ->
|
val remoteDetails = getDetails(remoteManga, force).getOrNull()
|
||||||
send(
|
emit(
|
||||||
MangaDetails(
|
MangaDetails(
|
||||||
manga = localManga,
|
manga = remoteDetails ?: remoteManga,
|
||||||
localManga = null,
|
localManga = LocalManga(localDetails),
|
||||||
override = override,
|
override = override,
|
||||||
description = localManga.description?.parseAsHtml(withImages = false)?.trim(),
|
description = (remoteDetails ?: localDetails).description?.parseAsHtml(withImages = true),
|
||||||
isLoaded = true,
|
isLoaded = true,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} ?: close(e)
|
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 {
|
private suspend fun getDetails(seed: Manga, force: Boolean) = runCatchingCancellable {
|
||||||
val repository = mangaRepositoryFactory.create(seed.source)
|
val repository = mangaRepositoryFactory.create(seed.source)
|
||||||
if (repository is CachingMangaRepository) {
|
if (repository is CachingMangaRepository) {
|
||||||
@@ -131,20 +164,18 @@ class DetailsLoadUseCase @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
null
|
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 {
|
private fun Spanned.filterSpans(): Spanned {
|
||||||
val spannable = SpannableString.valueOf(this)
|
val spannable = SpannableString.valueOf(this)
|
||||||
val spans = spannable.getSpans<ForegroundColorSpan>()
|
val spans = spannable.getSpans<ForegroundColorSpan>()
|
||||||
@@ -153,10 +184,4 @@ class DetailsLoadUseCase @Inject constructor(
|
|||||||
}
|
}
|
||||||
return spannable
|
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?,
|
branch: String?,
|
||||||
bookmarks: List<Bookmark>,
|
bookmarks: List<Bookmark>,
|
||||||
isGrid: Boolean,
|
isGrid: Boolean,
|
||||||
|
isDownloadedOnly: Boolean,
|
||||||
): List<ChapterListItem> {
|
): List<ChapterListItem> {
|
||||||
val remoteChapters = chapters[branch].orEmpty()
|
val remoteChapters = chapters[branch].orEmpty()
|
||||||
val localChapters = local?.manga?.getChapters(branch).orEmpty()
|
val localChapters = local?.manga?.getChapters(branch).orEmpty()
|
||||||
@@ -35,19 +36,21 @@ fun MangaDetails.mapChapters(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
var isUnread = currentChapterId !in ids
|
var isUnread = currentChapterId !in ids
|
||||||
for (chapter in remoteChapters) {
|
if (!isDownloadedOnly || local?.manga?.chapters == null) {
|
||||||
val local = localMap?.remove(chapter.id)
|
for (chapter in remoteChapters) {
|
||||||
if (chapter.id == currentChapterId) {
|
val local = localMap?.remove(chapter.id)
|
||||||
isUnread = true
|
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()) {
|
if (!localMap.isNullOrEmpty()) {
|
||||||
for (chapter in localMap.values) {
|
for (chapter in localMap.values) {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import android.view.ViewGroup
|
|||||||
import android.view.ViewTreeObserver
|
import android.view.ViewTreeObserver
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.widget.TooltipCompat
|
|
||||||
import androidx.core.text.buildSpannedString
|
import androidx.core.text.buildSpannedString
|
||||||
import androidx.core.text.inSpans
|
import androidx.core.text.inSpans
|
||||||
import androidx.core.text.method.LinkMovementMethodCompat
|
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.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
import org.koitharu.kotatsu.core.util.ext.parentView
|
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.start
|
||||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||||
@@ -260,7 +260,7 @@ class DetailsActivity :
|
|||||||
R.id.button_scrobbling_more -> {
|
R.id.button_scrobbling_more -> {
|
||||||
router.showScrobblingSelectorSheet(
|
router.showScrobblingSelectorSheet(
|
||||||
manga = viewModel.getMangaOrNull() ?: return,
|
manga = viewModel.getMangaOrNull() ?: return,
|
||||||
scrobblerService = viewModel.scrobblingInfo.value.firstOrNull()?.scrobbler
|
scrobblerService = viewModel.scrobblingInfo.value.firstOrNull()?.scrobbler,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,7 +389,7 @@ class DetailsActivity :
|
|||||||
mangaGridItemAD(
|
mangaGridItemAD(
|
||||||
sizeResolver = StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)),
|
sizeResolver = StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)),
|
||||||
) { item, view ->
|
) { item, view ->
|
||||||
router.openDetails(item)
|
router.openDetails(item.toMangaWithOverride())
|
||||||
},
|
},
|
||||||
).also { rv.adapter = it }
|
).also { rv.adapter = it }
|
||||||
adapter.items = related
|
adapter.items = related
|
||||||
@@ -455,7 +455,7 @@ class DetailsActivity :
|
|||||||
textViewSourceLabel.isVisible = false
|
textViewSourceLabel.isVisible = false
|
||||||
} else {
|
} else {
|
||||||
textViewSource.textAndVisible = manga.source.getTitle(this@DetailsActivity)
|
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
|
textViewSourceLabel.isVisible = textViewSource.isVisible == true
|
||||||
}
|
}
|
||||||
val faviconPlaceholderFactory = FaviconDrawable.Factory(R.style.FaviconDrawable_Chip)
|
val faviconPlaceholderFactory = FaviconDrawable.Factory(R.style.FaviconDrawable_Chip)
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ class DetailsViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
loadingJob = doLoad(force = false)
|
loadingJob = doLoad(force = false)
|
||||||
launchJob(Dispatchers.Default) {
|
launchJob(Dispatchers.Default + SkipErrors) {
|
||||||
val manga = mangaDetails.firstOrNull { !it?.chapters.isNullOrEmpty() } ?: return@launchJob
|
val manga = mangaDetails.firstOrNull { !it?.chapters.isNullOrEmpty() } ?: return@launchJob
|
||||||
val h = history.firstOrNull()
|
val h = history.firstOrNull()
|
||||||
if (h != null) {
|
if (h != null) {
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ class ReadButtonDelegate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun openReader(isIncognitoMode: Boolean) {
|
private fun openReader(isIncognitoMode: Boolean) {
|
||||||
val manga = viewModel.manga.value ?: return
|
val manga = viewModel.getMangaOrNull() ?: return
|
||||||
if (viewModel.historyInfo.value.isChapterMissing) {
|
if (viewModel.historyInfo.value.isChapterMissing) {
|
||||||
Snackbar.make(buttonRead, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT)
|
Snackbar.make(buttonRead, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT)
|
||||||
.show() // TODO
|
.show() // TODO
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
package org.koitharu.kotatsu.details.ui.adapter
|
package org.koitharu.kotatsu.details.ui.adapter
|
||||||
|
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
import androidx.appcompat.widget.TooltipCompat
|
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
|
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.databinding.ItemChapterGridBinding
|
||||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
@@ -23,7 +23,7 @@ fun chapterGridItemAD(
|
|||||||
bind { payloads ->
|
bind { payloads ->
|
||||||
if (payloads.isEmpty()) {
|
if (payloads.isEmpty()) {
|
||||||
binding.textViewTitle.text = item.chapter.numberString() ?: "?"
|
binding.textViewTitle.text = item.chapter.numberString() ?: "?"
|
||||||
TooltipCompat.setTooltipText(itemView, item.chapter.title)
|
itemView.setTooltipCompat(item.chapter.title)
|
||||||
}
|
}
|
||||||
binding.imageViewNew.isVisible = item.isNew
|
binding.imageViewNew.isVisible = item.isNew
|
||||||
binding.imageViewCurrent.isVisible = item.isCurrent
|
binding.imageViewCurrent.isVisible = item.isCurrent
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.core.view.MenuProvider
|
|||||||
import androidx.viewpager2.widget.ViewPager2
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
import com.google.android.material.slider.LabelFormatter
|
import com.google.android.material.slider.LabelFormatter
|
||||||
import com.google.android.material.slider.Slider
|
import com.google.android.material.slider.Slider
|
||||||
|
import com.google.android.material.slider.TickVisibilityMode
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||||
@@ -38,9 +39,13 @@ class ChapterPagesMenuProvider(
|
|||||||
setOnActionExpandListener(this@ChapterPagesMenuProvider)
|
setOnActionExpandListener(this@ChapterPagesMenuProvider)
|
||||||
(actionView as? SearchView)?.setupChaptersSearchView()
|
(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_reversed)?.isChecked = viewModel.isChaptersReversed.value == true
|
||||||
menu.findItem(R.id.action_grid_view)?.isChecked = viewModel.isChaptersInGridView.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 -> {
|
TAB_PAGES, TAB_BOOKMARKS -> {
|
||||||
@@ -64,6 +69,11 @@ class ChapterPagesMenuProvider(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
R.id.action_downloaded -> {
|
||||||
|
viewModel.isDownloadedOnly.value = !menuItem.isChecked
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +120,7 @@ class ChapterPagesMenuProvider(
|
|||||||
valueFrom = 50f
|
valueFrom = 50f
|
||||||
valueTo = 150f
|
valueTo = 150f
|
||||||
stepSize = 5f
|
stepSize = 5f
|
||||||
isTickVisible = false
|
tickVisibilityMode = TickVisibilityMode.TICK_VISIBILITY_HIDDEN
|
||||||
labelBehavior = LabelFormatter.LABEL_FLOATING
|
labelBehavior = LabelFormatter.LABEL_FLOATING
|
||||||
setLabelFormatter(IntPercentLabelFormatter(context))
|
setLabelFormatter(IntPercentLabelFormatter(context))
|
||||||
setValueRounded(settings.gridSizePages.toFloat())
|
setValueRounded(settings.gridSizePages.toFloat())
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(),
|
|||||||
val menuInvalidator = MenuInvalidator(binding.toolbar)
|
val menuInvalidator = MenuInvalidator(binding.toolbar)
|
||||||
viewModel.isChaptersReversed.observe(viewLifecycleOwner, menuInvalidator)
|
viewModel.isChaptersReversed.observe(viewLifecycleOwner, menuInvalidator)
|
||||||
viewModel.isChaptersInGridView.observe(viewLifecycleOwner, menuInvalidator)
|
viewModel.isChaptersInGridView.observe(viewLifecycleOwner, menuInvalidator)
|
||||||
|
viewModel.isDownloadedOnly.observe(viewLifecycleOwner, menuInvalidator)
|
||||||
|
|
||||||
actionModeDelegate?.addListener(this, viewLifecycleOwner)
|
actionModeDelegate?.addListener(this, viewLifecycleOwner)
|
||||||
addSheetCallback(this, viewLifecycleOwner)
|
addSheetCallback(this, viewLifecycleOwner)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
|||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.plus
|
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.DeleteLocalMangaUseCase
|
||||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||||
@@ -87,6 +89,8 @@ abstract class ChaptersPagesViewModel(
|
|||||||
valueProducer = { isChaptersGridView },
|
valueProducer = { isChaptersGridView },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val isDownloadedOnly = MutableStateFlow(false)
|
||||||
|
|
||||||
val newChaptersCount = mangaDetails.flatMapLatest { d ->
|
val newChaptersCount = mangaDetails.flatMapLatest { d ->
|
||||||
if (d?.isLocal == false) {
|
if (d?.isLocal == false) {
|
||||||
interactor.observeNewChapters(d.id)
|
interactor.observeNewChapters(d.id)
|
||||||
@@ -95,9 +99,19 @@ abstract class ChaptersPagesViewModel(
|
|||||||
}
|
}
|
||||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
|
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
|
||||||
|
|
||||||
val isChaptersEmpty: StateFlow<Boolean> = mangaDetails.map {
|
val emptyReason: StateFlow<EmptyMangaReason?> = combine(
|
||||||
it != null && it.isLoaded && it.allChapters.isEmpty()
|
mangaDetails,
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
|
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 {
|
val bookmarks = mangaDetails.flatMapLatest {
|
||||||
if (it != null) {
|
if (it != null) {
|
||||||
@@ -115,13 +129,15 @@ abstract class ChaptersPagesViewModel(
|
|||||||
newChaptersCount,
|
newChaptersCount,
|
||||||
bookmarks,
|
bookmarks,
|
||||||
isChaptersInGridView,
|
isChaptersInGridView,
|
||||||
) { manga, currentChapterId, branch, news, bookmarks, grid ->
|
isDownloadedOnly,
|
||||||
|
) { manga, currentChapterId, branch, news, bookmarks, grid, downloadedOnly ->
|
||||||
manga?.mapChapters(
|
manga?.mapChapters(
|
||||||
currentChapterId,
|
currentChapterId = currentChapterId,
|
||||||
news,
|
newCount = news,
|
||||||
branch,
|
branch = branch,
|
||||||
bookmarks,
|
bookmarks = bookmarks,
|
||||||
grid,
|
isGrid = grid,
|
||||||
|
isDownloadedOnly = downloadedOnly,
|
||||||
).orEmpty()
|
).orEmpty()
|
||||||
},
|
},
|
||||||
isChaptersReversed,
|
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.findAppCompatDelegate
|
||||||
import org.koitharu.kotatsu.core.util.ext.findParentCallback
|
import org.koitharu.kotatsu.core.util.ext.findParentCallback
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
|
||||||
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
|
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
|
||||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
|
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
|
||||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
|
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
|
||||||
@@ -96,8 +97,8 @@ class ChaptersFragment :
|
|||||||
.flowOn(Dispatchers.Default)
|
.flowOn(Dispatchers.Default)
|
||||||
.observe(viewLifecycleOwner, this::onChaptersChanged)
|
.observe(viewLifecycleOwner, this::onChaptersChanged)
|
||||||
viewModel.quickFilter.observe(viewLifecycleOwner, this::onFilterChanged)
|
viewModel.quickFilter.observe(viewLifecycleOwner, this::onFilterChanged)
|
||||||
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
|
viewModel.emptyReason.observe(viewLifecycleOwner) {
|
||||||
binding.textViewHolder.isVisible = it
|
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.fetch
|
||||||
import org.koitharu.kotatsu.core.util.ext.isNetworkUri
|
import org.koitharu.kotatsu.core.util.ext.isNetworkUri
|
||||||
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
|
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.model.MangaPage
|
||||||
import org.koitharu.kotatsu.parsers.util.mimeType
|
import org.koitharu.kotatsu.parsers.util.mimeType
|
||||||
import org.koitharu.kotatsu.parsers.util.requireBody
|
import org.koitharu.kotatsu.parsers.util.requireBody
|
||||||
@@ -34,7 +35,7 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
class MangaPageFetcher(
|
class MangaPageFetcher(
|
||||||
private val okHttpClient: OkHttpClient,
|
private val okHttpClient: OkHttpClient,
|
||||||
private val pagesCache: PagesCache,
|
private val pagesCache: LocalStorageCache,
|
||||||
private val options: Options,
|
private val options: Options,
|
||||||
private val page: MangaPage,
|
private val page: MangaPage,
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
@@ -53,7 +54,7 @@ class MangaPageFetcher(
|
|||||||
val repo = mangaRepositoryFactory.create(page.source)
|
val repo = mangaRepositoryFactory.create(page.source)
|
||||||
val pageUrl = repo.getPageUrl(page)
|
val pageUrl = repo.getPageUrl(page)
|
||||||
if (options.diskCachePolicy.readEnabled) {
|
if (options.diskCachePolicy.readEnabled) {
|
||||||
pagesCache.get(pageUrl)?.let { file ->
|
pagesCache[pageUrl]?.let { file ->
|
||||||
return SourceFetchResult(
|
return SourceFetchResult(
|
||||||
source = ImageSource(file.toOkioPath(), options.fileSystem),
|
source = ImageSource(file.toOkioPath(), options.fileSystem),
|
||||||
mimeType = MimeTypes.getMimeTypeFromExtension(file.name)?.toString(),
|
mimeType = MimeTypes.getMimeTypeFromExtension(file.name)?.toString(),
|
||||||
@@ -78,7 +79,7 @@ class MangaPageFetcher(
|
|||||||
}
|
}
|
||||||
val mimeType = response.mimeType?.toMimeTypeOrNull()
|
val mimeType = response.mimeType?.toMimeTypeOrNull()
|
||||||
val file = response.requireBody().use {
|
val file = response.requireBody().use {
|
||||||
pagesCache.put(pageUrl, it.source(), mimeType)
|
pagesCache.set(pageUrl, it.source(), mimeType)
|
||||||
}
|
}
|
||||||
SourceFetchResult(
|
SourceFetchResult(
|
||||||
source = ImageSource(file.toOkioPath(), FileSystem.SYSTEM),
|
source = ImageSource(file.toOkioPath(), FileSystem.SYSTEM),
|
||||||
@@ -107,7 +108,7 @@ class MangaPageFetcher(
|
|||||||
|
|
||||||
class Factory @Inject constructor(
|
class Factory @Inject constructor(
|
||||||
@MangaHttpClient private val okHttpClient: OkHttpClient,
|
@MangaHttpClient private val okHttpClient: OkHttpClient,
|
||||||
private val pagesCache: PagesCache,
|
@PageCache private val pagesCache: LocalStorageCache,
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
private val imageProxyInterceptor: ImageProxyInterceptor,
|
private val imageProxyInterceptor: ImageProxyInterceptor,
|
||||||
) : Fetcher.Factory<MangaPage> {
|
) : Fetcher.Factory<MangaPage> {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import androidx.appcompat.view.ActionMode
|
|||||||
import androidx.collection.ArraySet
|
import androidx.collection.ArraySet
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.isInvisible
|
import androidx.core.view.isInvisible
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
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.findParentCallback
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
|
||||||
import org.koitharu.kotatsu.core.util.ext.showOrHide
|
import org.koitharu.kotatsu.core.util.ext.showOrHide
|
||||||
import org.koitharu.kotatsu.databinding.FragmentPagesBinding
|
import org.koitharu.kotatsu.databinding.FragmentPagesBinding
|
||||||
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
|
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
|
||||||
|
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
|
||||||
import org.koitharu.kotatsu.list.ui.GridSpanResolver
|
import org.koitharu.kotatsu.list.ui.GridSpanResolver
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||||
@@ -125,11 +126,18 @@ class PagesFragment :
|
|||||||
it.spanCount = checkNotNull(spanResolver).spanCount
|
it.spanCount = checkNotNull(spanResolver).spanCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
parentViewModel.isChaptersEmpty.observe(viewLifecycleOwner, ::onNoChaptersChanged)
|
parentViewModel.emptyReason.observe(viewLifecycleOwner, ::onNoChaptersChanged)
|
||||||
viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged)
|
viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged)
|
||||||
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(binding.recyclerView))
|
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(binding.recyclerView))
|
||||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
|
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.isLoadingUp.observe(viewLifecycleOwner) { binding.progressBarTop.showOrHide(it) }
|
||||||
viewModel.isLoadingDown.observe(viewLifecycleOwner) { binding.progressBarBottom.showOrHide(it) }
|
viewModel.isLoadingDown.observe(viewLifecycleOwner) { binding.progressBarBottom.showOrHide(it) }
|
||||||
}
|
}
|
||||||
@@ -237,10 +245,10 @@ class PagesFragment :
|
|||||||
spanResolver?.setGridSize(scale, requireViewBinding().recyclerView)
|
spanResolver?.setGridSize(scale, requireViewBinding().recyclerView)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onNoChaptersChanged(isNoChapters: Boolean) {
|
private fun onNoChaptersChanged(reason: EmptyMangaReason?) {
|
||||||
with(viewBinding ?: return) {
|
with(viewBinding ?: return) {
|
||||||
textViewHolder.isVisible = isNoChapters
|
textViewHolder.setTextAndVisible(reason?.msgResId ?: 0)
|
||||||
recyclerView.isInvisible = isNoChapters
|
recyclerView.isInvisible = reason != null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.plus
|
import kotlinx.coroutines.plus
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||||
@@ -47,16 +48,15 @@ class PagesViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
launchJob(Dispatchers.Default) {
|
||||||
val firstState = state.firstNotNull()
|
state.filterNotNull()
|
||||||
doInit(firstState)
|
.collect {
|
||||||
launchJob(Dispatchers.Default) {
|
val prevJob = loadingJob
|
||||||
state.collectLatest {
|
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
||||||
if (it != null) {
|
prevJob?.cancelAndJoin()
|
||||||
doInit(it)
|
doInit(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.content.Context
|
|||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.work.HiltWorker
|
import androidx.hilt.work.HiltWorker
|
||||||
import androidx.work.BackoffPolicy
|
import androidx.work.BackoffPolicy
|
||||||
import androidx.work.Constraints
|
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.getDisplayMessage
|
||||||
import org.koitharu.kotatsu.core.util.ext.getWorkInputData
|
import org.koitharu.kotatsu.core.util.ext.getWorkInputData
|
||||||
import org.koitharu.kotatsu.core.util.ext.getWorkSpec
|
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.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
||||||
import org.koitharu.kotatsu.core.util.ext.toMimeType
|
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.withTicker
|
||||||
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
||||||
import org.koitharu.kotatsu.core.util.progress.RealtimeEtaEstimator
|
import org.koitharu.kotatsu.core.util.progress.RealtimeEtaEstimator
|
||||||
import org.koitharu.kotatsu.download.domain.DownloadProgress
|
import org.koitharu.kotatsu.download.domain.DownloadProgress
|
||||||
import org.koitharu.kotatsu.download.domain.DownloadState
|
import org.koitharu.kotatsu.download.domain.DownloadState
|
||||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
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.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.TempFileFilter
|
||||||
import org.koitharu.kotatsu.local.data.input.LocalMangaParser
|
import org.koitharu.kotatsu.local.data.input.LocalMangaParser
|
||||||
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
||||||
@@ -99,7 +104,7 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
@Assisted appContext: Context,
|
@Assisted appContext: Context,
|
||||||
@Assisted params: WorkerParameters,
|
@Assisted params: WorkerParameters,
|
||||||
@MangaHttpClient private val okHttp: OkHttpClient,
|
@MangaHttpClient private val okHttp: OkHttpClient,
|
||||||
private val cache: PagesCache,
|
@PageCache private val cache: LocalStorageCache,
|
||||||
private val localMangaRepository: LocalMangaRepository,
|
private val localMangaRepository: LocalMangaRepository,
|
||||||
private val mangaLock: MangaLock,
|
private val mangaLock: MangaLock,
|
||||||
private val mangaDataRepository: MangaDataRepository,
|
private val mangaDataRepository: MangaDataRepository,
|
||||||
@@ -229,7 +234,7 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
semaphore.withPermit {
|
semaphore.withPermit {
|
||||||
runFailsafe {
|
runFailsafe {
|
||||||
val url = repo.getPageUrl(page)
|
val url = repo.getPageUrl(page)
|
||||||
val file = cache.get(url)
|
val file = cache[url]
|
||||||
?: downloadFile(url, destination, repo.source)
|
?: downloadFile(url, destination, repo.source)
|
||||||
output.addPage(
|
output.addPage(
|
||||||
chapter = chapter,
|
chapter = chapter,
|
||||||
@@ -371,6 +376,25 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
destination: File,
|
destination: File,
|
||||||
source: MangaSource,
|
source: MangaSource,
|
||||||
): File {
|
): 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)
|
val request = PageLoader.createPageRequest(url, source)
|
||||||
slowdownDispatcher.delay(source)
|
slowdownDispatcher.delay(source)
|
||||||
return imageProxyInterceptor.interceptPageRequest(request, okHttp)
|
return imageProxyInterceptor.interceptPageRequest(request, okHttp)
|
||||||
@@ -379,22 +403,14 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
var file: File? = null
|
var file: File? = null
|
||||||
try {
|
try {
|
||||||
response.requireBody().use { body ->
|
response.requireBody().use { body ->
|
||||||
file = File(
|
file = destination.createTempFile(
|
||||||
destination,
|
ext = MimeTypes.getExtension(body.contentType()?.toMimeType())
|
||||||
buildString {
|
|
||||||
append(UUID.randomUUID().toString())
|
|
||||||
MimeTypes.getExtension(body.contentType()?.toMimeType())?.let { ext ->
|
|
||||||
append('.')
|
|
||||||
append(ext)
|
|
||||||
}
|
|
||||||
append(".tmp")
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
file.sink(append = false).buffer().use {
|
file.sink(append = false).buffer().use {
|
||||||
it.writeAllCancellable(body.source())
|
it.writeAllCancellable(body.source())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: CancellationException) {
|
} catch (e: Exception) {
|
||||||
file?.delete()
|
file?.delete()
|
||||||
throw e
|
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) {
|
private suspend fun publishState(state: DownloadState) {
|
||||||
val previousState = currentState
|
val previousState = currentState
|
||||||
lastPublishedState = state
|
lastPublishedState = state
|
||||||
@@ -537,7 +565,7 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val requests = tasks.map { (manga, task) ->
|
val requests = tasks.map { (manga, task) ->
|
||||||
mangaDataRepository.storeManga(manga)
|
mangaDataRepository.storeManga(manga, replaceExisting = true)
|
||||||
OneTimeWorkRequestBuilder<DownloadWorker>()
|
OneTimeWorkRequestBuilder<DownloadWorker>()
|
||||||
.setConstraints(createConstraints(task.allowMeteredNetwork))
|
.setConstraints(createConstraints(task.allowMeteredNetwork))
|
||||||
.addTag(TAG)
|
.addTag(TAG)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class ExploreRepository @Inject constructor(
|
|||||||
val details = runCatchingCancellable {
|
val details = runCatchingCancellable {
|
||||||
mangaRepositoryFactory.create(manga.source).getDetails(manga)
|
mangaRepositoryFactory.create(manga.source).getDetails(manga)
|
||||||
}.getOrNull() ?: continue
|
}.getOrNull() ?: continue
|
||||||
if ((settings.isSuggestionsExcludeNsfw && details.isNsfw) || details in tagsBlacklist) {
|
if ((settings.isSuggestionsExcludeNsfw && details.isNsfw()) || details in tagsBlacklist) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return details
|
return details
|
||||||
@@ -55,7 +55,7 @@ class ExploreRepository @Inject constructor(
|
|||||||
val details = runCatchingCancellable {
|
val details = runCatchingCancellable {
|
||||||
mangaRepositoryFactory.create(manga.source).getDetails(manga)
|
mangaRepositoryFactory.create(manga.source).getDetails(manga)
|
||||||
}.getOrNull() ?: continue
|
}.getOrNull() ?: continue
|
||||||
if ((skipNsfw && details.isNsfw) || details in tagsBlacklist) {
|
if ((skipNsfw && details.isNsfw()) || details in tagsBlacklist) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return details
|
return details
|
||||||
@@ -80,7 +80,7 @@ class ExploreRepository @Inject constructor(
|
|||||||
filter = MangaListFilter(tags = setOfNotNull(tag)),
|
filter = MangaListFilter(tags = setOfNotNull(tag)),
|
||||||
).asArrayList()
|
).asArrayList()
|
||||||
if (settings.isSuggestionsExcludeNsfw) {
|
if (settings.isSuggestionsExcludeNsfw) {
|
||||||
list.removeAll { it.isNsfw }
|
list.removeAll { it.isNsfw() }
|
||||||
}
|
}
|
||||||
if (blacklist.isNotEmpty()) {
|
if (blacklist.isNotEmpty()) {
|
||||||
list.removeAll { manga -> manga in blacklist }
|
list.removeAll { manga -> manga in blacklist }
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class RecoverMangaUseCase @Inject constructor(
|
|||||||
repository.getDetails(it)
|
repository.getDetails(it)
|
||||||
} ?: return@runCatchingCancellable null
|
} ?: return@runCatchingCancellable null
|
||||||
val merged = merge(manga, newManga)
|
val merged = merge(manga, newManga)
|
||||||
mangaDataRepository.storeManga(merged)
|
mangaDataRepository.storeManga(merged, replaceExisting = true)
|
||||||
merged
|
merged
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
it.printStackTraceDebug()
|
it.printStackTraceDebug()
|
||||||
|
|||||||
@@ -198,11 +198,9 @@ class ExploreViewModel @Inject constructor(
|
|||||||
|
|
||||||
private fun List<Manga>.toRecommendationList() = map { manga ->
|
private fun List<Manga>.toRecommendationList() = map { manga ->
|
||||||
MangaCompactListModel(
|
MangaCompactListModel(
|
||||||
id = manga.id,
|
|
||||||
title = manga.title,
|
|
||||||
subtitle = manga.tags.joinToString { it.title },
|
|
||||||
coverUrl = manga.coverUrl,
|
|
||||||
manga = manga,
|
manga = manga,
|
||||||
|
override = null,
|
||||||
|
subtitle = manga.tags.joinToString { it.title },
|
||||||
counter = 0,
|
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.drawableStart
|
||||||
import org.koitharu.kotatsu.core.util.ext.recyclerView
|
import org.koitharu.kotatsu.core.util.ext.recyclerView
|
||||||
import org.koitharu.kotatsu.core.util.ext.setProgressIcon
|
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.core.util.ext.textAndVisible
|
||||||
import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding
|
import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding
|
||||||
import org.koitharu.kotatsu.databinding.ItemExploreSourceGridBinding
|
import org.koitharu.kotatsu.databinding.ItemExploreSourceGridBinding
|
||||||
@@ -126,8 +127,7 @@ fun exploreSourceGridItemAD(
|
|||||||
|
|
||||||
bind {
|
bind {
|
||||||
val title = item.source.getTitle(context)
|
val title = item.source.getTitle(context)
|
||||||
TooltipCompat.setTooltipText(
|
itemView.setTooltipCompat(
|
||||||
itemView,
|
|
||||||
buildSpannedString {
|
buildSpannedString {
|
||||||
bold {
|
bold {
|
||||||
append(title)
|
append(title)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.core.view.isVisible
|
|||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
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.ItemCategoriesAllBinding
|
||||||
import org.koitharu.kotatsu.databinding.ItemCategoryBinding
|
import org.koitharu.kotatsu.databinding.ItemCategoryBinding
|
||||||
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
|
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
|
||||||
@@ -91,6 +92,13 @@ fun allCategoriesAD(
|
|||||||
R.drawable.ic_eye_off
|
R.drawable.ic_eye_off
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
binding.imageViewVisible.setTooltipCompat(
|
||||||
|
if (item.isVisible) {
|
||||||
|
R.string.hide
|
||||||
|
} else {
|
||||||
|
R.string.show
|
||||||
|
},
|
||||||
|
)
|
||||||
binding.coversView.setCoversAsync(item.covers)
|
binding.coversView.setCoversAsync(item.covers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.filter.ui
|
package org.koitharu.kotatsu.filter.ui
|
||||||
|
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import dagger.hilt.android.ViewModelLifecycle
|
import dagger.hilt.android.ViewModelLifecycle
|
||||||
import dagger.hilt.android.scopes.ViewModelScoped
|
import dagger.hilt.android.scopes.ViewModelScoped
|
||||||
@@ -489,9 +490,27 @@ class FilterCoordinator @Inject constructor(
|
|||||||
val filterCoordinator: FilterCoordinator
|
val filterCoordinator: FilterCoordinator
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
companion object {
|
||||||
|
|
||||||
const val TAGS_LIMIT = 12
|
private const val TAGS_LIMIT = 12
|
||||||
val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
|||||||
binding.scrollView.scrollIndicators = 0
|
binding.scrollView.scrollIndicators = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val filter = requireFilter()
|
val filter = FilterCoordinator.require(this)
|
||||||
filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
|
filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
|
||||||
filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged)
|
filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged)
|
||||||
filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged)
|
filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged)
|
||||||
@@ -103,7 +103,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
|
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
|
||||||
val filter = requireFilter()
|
val filter = FilterCoordinator.require(this)
|
||||||
when (parent.id) {
|
when (parent.id) {
|
||||||
R.id.spinner_order -> filter.setSortOrder(filter.sortOrder.value.availableItems[position])
|
R.id.spinner_order -> filter.setSortOrder(filter.sortOrder.value.availableItems[position])
|
||||||
R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position])
|
R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position])
|
||||||
@@ -118,7 +118,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val intValue = value.toInt()
|
val intValue = value.toInt()
|
||||||
val filter = requireFilter()
|
val filter = FilterCoordinator.require(this)
|
||||||
when (slider.id) {
|
when (slider.id) {
|
||||||
R.id.slider_year -> filter.setYear(
|
R.id.slider_year -> filter.setYear(
|
||||||
if (intValue <= slider.valueFrom.toIntUp()) {
|
if (intValue <= slider.valueFrom.toIntUp()) {
|
||||||
@@ -134,7 +134,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
|||||||
if (!fromUser) {
|
if (!fromUser) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val filter = requireFilter()
|
val filter = FilterCoordinator.require(this)
|
||||||
when (slider.id) {
|
when (slider.id) {
|
||||||
R.id.slider_yearsRange -> filter.setYearRange(
|
R.id.slider_yearsRange -> filter.setYearRange(
|
||||||
valueFrom = slider.values.firstOrNull()?.let {
|
valueFrom = slider.values.firstOrNull()?.let {
|
||||||
@@ -148,7 +148,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onChipClick(chip: Chip, data: Any?) {
|
override fun onChipClick(chip: Chip, data: Any?) {
|
||||||
val filter = requireFilter()
|
val filter = FilterCoordinator.require(this)
|
||||||
when (data) {
|
when (data) {
|
||||||
is MangaState -> filter.toggleState(data, !chip.isChecked)
|
is MangaState -> filter.toggleState(data, !chip.isChecked)
|
||||||
is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) {
|
is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) {
|
||||||
@@ -356,6 +356,4 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
|||||||
)
|
)
|
||||||
b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo)
|
b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requireFilter() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class TagsCatalogSheet : BaseAdaptiveSheet<SheetTagsBinding>(),
|
|||||||
extrasProducer = {
|
extrasProducer = {
|
||||||
defaultViewModelCreationExtras.withCreationCallback<TagsCatalogViewModel.Factory> { factory ->
|
defaultViewModelCreationExtras.withCreationCallback<TagsCatalogViewModel.Factory> { factory ->
|
||||||
factory.create(
|
factory.create(
|
||||||
filter = (requireActivity() as FilterCoordinator.Owner).filterCoordinator,
|
filter = FilterCoordinator.require(this),
|
||||||
isExcludeTag = requireArguments().getBoolean(AppRouter.KEY_EXCLUDE),
|
isExcludeTag = requireArguments().getBoolean(AppRouter.KEY_EXCLUDE),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import androidx.room.withTransaction
|
|||||||
import dagger.Reusable
|
import dagger.Reusable
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filter
|
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onStart
|
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.db.entity.toEntity
|
import org.koitharu.kotatsu.core.db.entity.toEntity
|
||||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||||
@@ -118,7 +116,7 @@ class HistoryRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
assert(manga.chapters != null)
|
assert(manga.chapters != null)
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
mangaRepository.storeManga(manga)
|
mangaRepository.storeManga(manga, replaceExisting = true)
|
||||||
val branch = manga.chapters?.findById(chapterId)?.branch
|
val branch = manga.chapters?.findById(chapterId)?.branch
|
||||||
db.getHistoryDao().upsert(
|
db.getHistoryDao().upsert(
|
||||||
HistoryEntity(
|
HistoryEntity(
|
||||||
@@ -204,9 +202,7 @@ class HistoryRepository @Inject constructor(
|
|||||||
fun shouldSkip(manga: Manga): Boolean = settings.isIncognitoModeEnabled(manga.isNsfw())
|
fun shouldSkip(manga: Manga): Boolean = settings.isIncognitoModeEnabled(manga.isNsfw())
|
||||||
|
|
||||||
fun observeShouldSkip(manga: Manga): Flow<Boolean> {
|
fun observeShouldSkip(manga: Manga): Flow<Boolean> {
|
||||||
return settings.observe()
|
return settings.observe(AppSettings.KEY_INCOGNITO_MODE, AppSettings.KEY_INCOGNITO_NSFW)
|
||||||
.filter { key -> key == AppSettings.KEY_INCOGNITO_MODE || key == AppSettings.KEY_INCOGNITO_NSFW }
|
|
||||||
.onStart { emit("") }
|
|
||||||
.map { shouldSkip(manga) }
|
.map { shouldSkip(manga) }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
package org.koitharu.kotatsu.image.ui
|
package org.koitharu.kotatsu.image.ui
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.drawable.LayerDrawable
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
|
import android.view.Gravity
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.ViewTreeObserver
|
import android.view.ViewTreeObserver
|
||||||
import android.view.ViewTreeObserver.OnPreDrawListener
|
import android.view.ViewTreeObserver.OnPreDrawListener
|
||||||
import androidx.annotation.AttrRes
|
import androidx.annotation.AttrRes
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.withStyledAttributes
|
import androidx.core.content.withStyledAttributes
|
||||||
import androidx.core.graphics.ColorUtils
|
import androidx.core.graphics.ColorUtils
|
||||||
import androidx.core.graphics.drawable.toDrawable
|
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.bookmarkExtra
|
||||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
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.mangaExtra
|
||||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||||
import org.koitharu.kotatsu.favourites.domain.model.Cover
|
import org.koitharu.kotatsu.favourites.domain.model.Cover
|
||||||
@@ -185,8 +189,17 @@ class CoverImageView @JvmOverloads constructor(
|
|||||||
|
|
||||||
override fun onError(request: ImageRequest, result: ErrorResult) {
|
override fun onError(request: ImageRequest, result: ErrorResult) {
|
||||||
super.onError(request, result)
|
super.onError(request, result)
|
||||||
foreground = result.throwable.getShortMessage()?.let { text ->
|
foreground = if (result.throwable.isNetworkError() && !networkState.isOnline()) {
|
||||||
TextDrawable.create(context, text, materialR.attr.textAppearanceTitleSmall)
|
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.local.data.index.LocalMangaIndex
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
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.TrackingRepository
|
||||||
|
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
|
||||||
|
import org.koitharu.kotatsu.tracker.ui.feed.model.FeedItem
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@Reusable
|
@Reusable
|
||||||
@@ -77,6 +78,14 @@ class MangaListMapper @Inject constructor(
|
|||||||
override = dataRepository.getOverride(manga.id),
|
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 {
|
fun mapTags(tags: Collection<MangaTag>) = tags.map {
|
||||||
ChipsView.ChipModel(
|
ChipsView.ChipModel(
|
||||||
tint = getTagTint(it),
|
tint = getTagTint(it),
|
||||||
@@ -90,11 +99,9 @@ class MangaListMapper @Inject constructor(
|
|||||||
@Options options: Int,
|
@Options options: Int,
|
||||||
override: MangaOverride?,
|
override: MangaOverride?,
|
||||||
) = MangaCompactListModel(
|
) = MangaCompactListModel(
|
||||||
id = manga.id,
|
|
||||||
title = override?.title.ifNullOrEmpty { manga.title },
|
|
||||||
subtitle = manga.tags.joinToString(", ") { it.title },
|
|
||||||
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
|
|
||||||
manga = manga,
|
manga = manga,
|
||||||
|
override = override,
|
||||||
|
subtitle = manga.tags.joinToString(", ") { it.title },
|
||||||
counter = getCounter(manga.id, options),
|
counter = getCounter(manga.id, options),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -103,11 +110,9 @@ class MangaListMapper @Inject constructor(
|
|||||||
@Options options: Int,
|
@Options options: Int,
|
||||||
override: MangaOverride?,
|
override: MangaOverride?,
|
||||||
) = MangaDetailedListModel(
|
) = MangaDetailedListModel(
|
||||||
id = manga.id,
|
|
||||||
title = override?.title.ifNullOrEmpty { manga.title },
|
|
||||||
subtitle = manga.altTitles.firstOrNull(),
|
subtitle = manga.altTitles.firstOrNull(),
|
||||||
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
|
|
||||||
manga = manga,
|
manga = manga,
|
||||||
|
override = override,
|
||||||
counter = getCounter(manga.id, options),
|
counter = getCounter(manga.id, options),
|
||||||
progress = getProgress(manga.id, options),
|
progress = getProgress(manga.id, options),
|
||||||
isFavorite = isFavorite(manga.id, options),
|
isFavorite = isFavorite(manga.id, options),
|
||||||
@@ -120,10 +125,8 @@ class MangaListMapper @Inject constructor(
|
|||||||
@Options options: Int,
|
@Options options: Int,
|
||||||
override: MangaOverride?
|
override: MangaOverride?
|
||||||
) = MangaGridModel(
|
) = MangaGridModel(
|
||||||
id = manga.id,
|
|
||||||
title = override?.title.ifNullOrEmpty { manga.title },
|
|
||||||
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
|
|
||||||
manga = manga,
|
manga = manga,
|
||||||
|
override = override,
|
||||||
counter = getCounter(manga.id, options),
|
counter = getCounter(manga.id, options),
|
||||||
progress = getProgress(manga.id, options),
|
progress = getProgress(manga.id, options),
|
||||||
isFavorite = isFavorite(manga.id, options),
|
isFavorite = isFavorite(manga.id, options),
|
||||||
|
|||||||
@@ -153,19 +153,20 @@ abstract class MangaListFragment :
|
|||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemClick(item: Manga, view: View) {
|
override fun onItemClick(item: MangaListModel, view: View) {
|
||||||
if (selectionController?.onItemClick(item.id) != true) {
|
if (selectionController?.onItemClick(item.id) != true) {
|
||||||
if ((activity as? MangaListActivity)?.showPreview(item) != true) {
|
val manga = item.toMangaWithOverride()
|
||||||
router.openDetails(item)
|
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
|
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
|
return selectionController?.onItemContextClick(view, item.id) == true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.filter
|
|||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.plus
|
import kotlinx.coroutines.plus
|
||||||
|
import org.koitharu.kotatsu.core.model.isNsfw
|
||||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||||
@@ -45,7 +46,7 @@ abstract class MangaListViewModel(
|
|||||||
abstract fun onRetry()
|
abstract fun onRetry()
|
||||||
|
|
||||||
protected fun List<Manga>.skipNsfwIfNeeded() = if (settings.isNsfwContentDisabled) {
|
protected fun List<Manga>.skipNsfwIfNeeded() = if (settings.isNsfwContentDisabled) {
|
||||||
filterNot { it.isNsfw }
|
filterNot { it.isNsfw() }
|
||||||
} else {
|
} else {
|
||||||
this
|
this
|
||||||
}
|
}
|
||||||
@@ -63,7 +64,7 @@ abstract class MangaListViewModel(
|
|||||||
protected fun observeListModeWithTriggers(): Flow<ListMode> = combine(
|
protected fun observeListModeWithTriggers(): Flow<ListMode> = combine(
|
||||||
listMode,
|
listMode,
|
||||||
mangaDataRepository.observeOverridesTrigger(emitInitialState = true),
|
mangaDataRepository.observeOverridesTrigger(emitInitialState = true),
|
||||||
settings.observe().filter { key ->
|
settings.observeChanges().filter { key ->
|
||||||
key == AppSettings.KEY_PROGRESS_INDICATORS
|
key == AppSettings.KEY_PROGRESS_INDICATORS
|
||||||
|| key == AppSettings.KEY_TRACKER_ENABLED
|
|| key == AppSettings.KEY_TRACKER_ENABLED
|
||||||
|| key == AppSettings.KEY_QUICK_FILTER
|
|| key == AppSettings.KEY_QUICK_FILTER
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ package org.koitharu.kotatsu.list.ui.adapter
|
|||||||
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.MangaListModel
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
|
||||||
interface MangaDetailsClickListener : OnListItemClickListener<Manga> {
|
interface MangaDetailsClickListener : OnListItemClickListener<MangaListModel> {
|
||||||
|
|
||||||
fun onReadClick(manga: Manga, view: View)
|
fun onReadClick(manga: Manga, view: View)
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
package org.koitharu.kotatsu.list.ui.adapter
|
package org.koitharu.kotatsu.list.ui.adapter
|
||||||
|
|
||||||
import androidx.appcompat.widget.TooltipCompat
|
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.setTooltipCompat
|
||||||
import org.koitharu.kotatsu.databinding.ItemMangaGridBinding
|
import org.koitharu.kotatsu.databinding.ItemMangaGridBinding
|
||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
|
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.MangaListModel
|
||||||
import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver
|
import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
|
|
||||||
fun mangaGridItemAD(
|
fun mangaGridItemAD(
|
||||||
sizeResolver: ItemSizeResolver,
|
sizeResolver: ItemSizeResolver,
|
||||||
clickListener: OnListItemClickListener<Manga>,
|
clickListener: OnListItemClickListener<MangaListModel>,
|
||||||
) = adapterDelegateViewBinding<MangaGridModel, ListModel, ItemMangaGridBinding>(
|
) = adapterDelegateViewBinding<MangaGridModel, ListModel, ItemMangaGridBinding>(
|
||||||
{ inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) },
|
{ inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) },
|
||||||
) {
|
) {
|
||||||
|
|
||||||
AdapterDelegateClickListenerAdapter(this, clickListener, MangaGridModel::manga).attach(itemView)
|
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
||||||
sizeResolver.attachToView(itemView, binding.textViewTitle, binding.progressView)
|
sizeResolver.attachToView(itemView, binding.textViewTitle, binding.progressView)
|
||||||
|
|
||||||
bind { payloads ->
|
bind { payloads ->
|
||||||
TooltipCompat.setTooltipText(itemView, item.getSummary(context))
|
itemView.setTooltipCompat(item.getSummary(context))
|
||||||
binding.textViewTitle.text = item.title
|
binding.textViewTitle.text = item.title
|
||||||
binding.progressView.setProgress(item.progress, PAYLOAD_PROGRESS_CHANGED in payloads)
|
binding.progressView.setProgress(item.progress, PAYLOAD_PROGRESS_CHANGED in payloads)
|
||||||
with(binding.iconsView) {
|
with(binding.iconsView) {
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ fun mangaListDetailedItemAD(
|
|||||||
{ inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) },
|
{ inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) },
|
||||||
) {
|
) {
|
||||||
|
|
||||||
AdapterDelegateClickListenerAdapter(this, clickListener, MangaDetailedListModel::manga).attach(itemView)
|
AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||||
|
.attach(itemView)
|
||||||
|
|
||||||
bind { payloads ->
|
bind { payloads ->
|
||||||
binding.textViewTitle.text = item.title
|
binding.textViewTitle.text = item.title
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
package org.koitharu.kotatsu.list.ui.adapter
|
package org.koitharu.kotatsu.list.ui.adapter
|
||||||
|
|
||||||
import androidx.appcompat.widget.TooltipCompat
|
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.setTooltipCompat
|
||||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||||
import org.koitharu.kotatsu.databinding.ItemMangaListBinding
|
import org.koitharu.kotatsu.databinding.ItemMangaListBinding
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel
|
import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.list.ui.model.MangaListModel
|
||||||
|
|
||||||
fun mangaListItemAD(
|
fun mangaListItemAD(
|
||||||
clickListener: OnListItemClickListener<Manga>,
|
clickListener: OnListItemClickListener<MangaListModel>,
|
||||||
) = adapterDelegateViewBinding<MangaCompactListModel, ListModel, ItemMangaListBinding>(
|
) = adapterDelegateViewBinding<MangaCompactListModel, ListModel, ItemMangaListBinding>(
|
||||||
{ inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) },
|
{ inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) },
|
||||||
) {
|
) {
|
||||||
|
|
||||||
AdapterDelegateClickListenerAdapter(this, clickListener, MangaCompactListModel::manga).attach(itemView)
|
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
||||||
|
|
||||||
bind {
|
bind {
|
||||||
TooltipCompat.setTooltipText(itemView, item.getSummary(context))
|
itemView.setTooltipCompat(item.getSummary(context))
|
||||||
binding.textViewTitle.text = item.title
|
binding.textViewTitle.text = item.title
|
||||||
binding.textViewSubtitle.textAndVisible = item.subtitle
|
binding.textViewSubtitle.textAndVisible = item.subtitle
|
||||||
binding.imageViewCover.setImageAsync(item.coverUrl, item.manga)
|
binding.imageViewCover.setImageAsync(item.coverUrl, item.manga)
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
package org.koitharu.kotatsu.list.ui.model
|
package org.koitharu.kotatsu.list.ui.model
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.core.ui.model.MangaOverride
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
data class MangaCompactListModel(
|
data class MangaCompactListModel(
|
||||||
override val id: Long,
|
|
||||||
override val title: String,
|
|
||||||
val subtitle: String,
|
|
||||||
override val coverUrl: String?,
|
|
||||||
override val manga: Manga,
|
override val manga: Manga,
|
||||||
|
override val override: MangaOverride?,
|
||||||
|
val subtitle: String,
|
||||||
override val counter: Int,
|
override val counter: Int,
|
||||||
) : MangaListModel()
|
) : MangaListModel()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.list.ui.model
|
package org.koitharu.kotatsu.list.ui.model
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.core.ui.model.MangaOverride
|
||||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||||
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED
|
||||||
@@ -7,11 +8,9 @@ import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROG
|
|||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
data class MangaDetailedListModel(
|
data class MangaDetailedListModel(
|
||||||
override val id: Long,
|
|
||||||
override val title: String,
|
|
||||||
val subtitle: String?,
|
|
||||||
override val coverUrl: String?,
|
|
||||||
override val manga: Manga,
|
override val manga: Manga,
|
||||||
|
override val override: MangaOverride?,
|
||||||
|
val subtitle: String?,
|
||||||
override val counter: Int,
|
override val counter: Int,
|
||||||
val progress: ReadingProgress?,
|
val progress: ReadingProgress?,
|
||||||
val isFavorite: Boolean,
|
val isFavorite: Boolean,
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
package org.koitharu.kotatsu.list.ui.model
|
package org.koitharu.kotatsu.list.ui.model
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.core.ui.model.MangaOverride
|
||||||
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED
|
||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
data class MangaGridModel(
|
data class MangaGridModel(
|
||||||
override val id: Long,
|
|
||||||
override val title: String,
|
|
||||||
override val coverUrl: String?,
|
|
||||||
override val manga: Manga,
|
override val manga: Manga,
|
||||||
|
override val override: MangaOverride?,
|
||||||
override val counter: Int,
|
override val counter: Int,
|
||||||
val progress: ReadingProgress?,
|
val progress: ReadingProgress?,
|
||||||
val isFavorite: Boolean,
|
val isFavorite: Boolean,
|
||||||
|
|||||||
@@ -4,21 +4,33 @@ import android.content.Context
|
|||||||
import androidx.core.text.bold
|
import androidx.core.text.bold
|
||||||
import androidx.core.text.buildSpannedString
|
import androidx.core.text.buildSpannedString
|
||||||
import org.koitharu.kotatsu.core.model.getTitle
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
|
import org.koitharu.kotatsu.core.model.withOverride
|
||||||
|
import org.koitharu.kotatsu.core.ui.model.MangaOverride
|
||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||||
|
|
||||||
sealed class MangaListModel : ListModel {
|
sealed class MangaListModel : ListModel {
|
||||||
|
|
||||||
abstract val id: Long
|
abstract val override: MangaOverride?
|
||||||
abstract val manga: Manga
|
abstract val manga: Manga
|
||||||
abstract val title: String
|
|
||||||
abstract val coverUrl: String?
|
|
||||||
abstract val counter: Int
|
abstract val counter: Int
|
||||||
|
|
||||||
|
val id: Long
|
||||||
|
get() = manga.id
|
||||||
|
|
||||||
|
val title: String
|
||||||
|
get() = override?.title.ifNullOrEmpty { manga.title }
|
||||||
|
|
||||||
|
val coverUrl: String?
|
||||||
|
get() = override?.coverUrl.ifNullOrEmpty { manga.coverUrl }
|
||||||
|
|
||||||
val source: MangaSource
|
val source: MangaSource
|
||||||
get() = manga.source
|
get() = manga.source
|
||||||
|
|
||||||
|
fun toMangaWithOverride() = manga.withOverride(override)
|
||||||
|
|
||||||
open fun getSummary(context: Context): CharSequence = buildSpannedString {
|
open fun getSummary(context: Context): CharSequence = buildSpannedString {
|
||||||
bold {
|
bold {
|
||||||
append(manga.title)
|
append(manga.title)
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
|
|||||||
|
|
||||||
override fun onChipClick(chip: Chip, data: Any?) {
|
override fun onChipClick(chip: Chip, data: Any?) {
|
||||||
val tag = data as? MangaTag ?: return
|
val tag = data as? MangaTag ?: return
|
||||||
val filter = (activity as? FilterCoordinator.Owner)?.filterCoordinator
|
val filter = FilterCoordinator.find(this)
|
||||||
if (filter == null) {
|
if (filter == null) {
|
||||||
router.openList(tag)
|
router.openList(tag)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.koitharu.kotatsu.local.data
|
||||||
|
|
||||||
|
import javax.inject.Qualifier
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class PageCache
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class FaviconCache
|
||||||
@@ -12,6 +12,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||||
import org.koitharu.kotatsu.core.model.isLocal
|
import org.koitharu.kotatsu.core.model.isLocal
|
||||||
|
import org.koitharu.kotatsu.core.model.isNsfw
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.util.AlphanumComparator
|
import org.koitharu.kotatsu.core.util.AlphanumComparator
|
||||||
@@ -94,7 +95,7 @@ class LocalMangaRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
val list = getRawList()
|
val list = getRawList()
|
||||||
if (settings.isNsfwContentDisabled) {
|
if (settings.isNsfwContentDisabled) {
|
||||||
list.removeAll { it.manga.isNsfw }
|
list.removeAll { it.manga.isNsfw() }
|
||||||
}
|
}
|
||||||
if (filter != null) {
|
if (filter != null) {
|
||||||
val query = filter.query
|
val query = filter.query
|
||||||
@@ -109,7 +110,7 @@ class LocalMangaRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
filter.contentRating.singleOrNull()?.let { contentRating ->
|
filter.contentRating.singleOrNull()?.let { contentRating ->
|
||||||
val isNsfw = contentRating == ContentRating.ADULT
|
val isNsfw = contentRating == ContentRating.ADULT
|
||||||
list.retainAll { it.manga.isNsfw == isNsfw }
|
list.retainAll { it.manga.isNsfw() == isNsfw }
|
||||||
}
|
}
|
||||||
if (!query.isNullOrEmpty() && order == SortOrder.RELEVANCE) {
|
if (!query.isNullOrEmpty() && order == SortOrder.RELEVANCE) {
|
||||||
list.sortBy { it.manga.title.levenshteinDistance(query) }
|
list.sortBy { it.manga.title.levenshteinDistance(query) }
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import android.graphics.Bitmap
|
|||||||
import android.os.StatFs
|
import android.os.StatFs
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
import com.tomclaw.cache.DiskLruCache
|
import com.tomclaw.cache.DiskLruCache
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -14,7 +13,6 @@ import okio.buffer
|
|||||||
import okio.sink
|
import okio.sink
|
||||||
import okio.use
|
import okio.use
|
||||||
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
|
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
|
||||||
import org.koitharu.kotatsu.core.util.FileSize
|
|
||||||
import org.koitharu.kotatsu.core.util.MimeTypes
|
import org.koitharu.kotatsu.core.util.MimeTypes
|
||||||
import org.koitharu.kotatsu.core.util.ext.MimeType
|
import org.koitharu.kotatsu.core.util.ext.MimeType
|
||||||
import org.koitharu.kotatsu.core.util.ext.compressToPNG
|
import org.koitharu.kotatsu.core.util.ext.compressToPNG
|
||||||
@@ -28,22 +26,24 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|||||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
class LocalStorageCache(
|
||||||
class PagesCache @Inject constructor(@ApplicationContext context: Context) {
|
context: Context,
|
||||||
|
private val dir: CacheDir,
|
||||||
|
private val defaultSize: Long,
|
||||||
|
private val minSize: Long,
|
||||||
|
) {
|
||||||
|
|
||||||
private val cacheDir = suspendLazy {
|
private val cacheDir = suspendLazy {
|
||||||
val dirs = context.externalCacheDirs + context.cacheDir
|
val dirs = context.externalCacheDirs + context.cacheDir
|
||||||
dirs.firstNotNullOf {
|
dirs.firstNotNullOf {
|
||||||
it?.subdir(CacheDir.PAGES.dir)?.takeIfWriteable()
|
it?.subdir(dir.dir)?.takeIfWriteable()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private val lruCache = suspendLazy {
|
private val lruCache = suspendLazy {
|
||||||
val dir = cacheDir.get()
|
val dir = cacheDir.get()
|
||||||
val availableSize = (getAvailableSize() * 0.8).toLong()
|
val availableSize = (getAvailableSize() * 0.8).toLong()
|
||||||
val size = SIZE_DEFAULT.coerceAtMost(availableSize).coerceAtLeast(SIZE_MIN)
|
val size = defaultSize.coerceAtMost(availableSize).coerceAtLeast(minSize)
|
||||||
runCatchingCancellable {
|
runCatchingCancellable {
|
||||||
DiskLruCache.create(dir, size)
|
DiskLruCache.create(dir, size)
|
||||||
}.recoverCatching { error ->
|
}.recoverCatching { error ->
|
||||||
@@ -54,14 +54,14 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
}.getOrThrow()
|
}.getOrThrow()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun get(url: String): File? = withContext(Dispatchers.IO) {
|
suspend operator fun get(url: String): File? = withContext(Dispatchers.IO) {
|
||||||
val cache = lruCache.get()
|
val cache = lruCache.get()
|
||||||
runInterruptible {
|
runInterruptible {
|
||||||
cache.get(url)?.takeIfReadable()
|
cache.get(url)?.takeIfReadable()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun put(url: String, source: Source, mimeType: MimeType?): File = withContext(Dispatchers.IO) {
|
suspend operator fun set(url: String, source: Source, mimeType: MimeType?): File = withContext(Dispatchers.IO) {
|
||||||
val file = createBufferFile(url, mimeType)
|
val file = createBufferFile(url, mimeType)
|
||||||
try {
|
try {
|
||||||
val bytes = file.sink(append = false).buffer().use {
|
val bytes = file.sink(append = false).buffer().use {
|
||||||
@@ -79,7 +79,7 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun put(url: String, bitmap: Bitmap): File = withContext(Dispatchers.IO) {
|
suspend operator fun set(url: String, bitmap: Bitmap): File = withContext(Dispatchers.IO) {
|
||||||
val file = createBufferFile(url, MimeType("image/png"))
|
val file = createBufferFile(url, MimeType("image/png"))
|
||||||
try {
|
try {
|
||||||
bitmap.compressToPNG(file)
|
bitmap.compressToPNG(file)
|
||||||
@@ -107,7 +107,7 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
}
|
}
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
it.printStackTraceDebug()
|
it.printStackTraceDebug()
|
||||||
}.getOrDefault(SIZE_DEFAULT)
|
}.getOrDefault(defaultSize)
|
||||||
|
|
||||||
private suspend fun createBufferFile(url: String, mimeType: MimeType?): File {
|
private suspend fun createBufferFile(url: String, mimeType: MimeType?): File {
|
||||||
val ext = MimeTypes.getExtension(mimeType) ?: MimeTypeMap.getFileExtensionFromUrl(url).ifNullOrEmpty { "dat" }
|
val ext = MimeTypes.getExtension(mimeType) ?: MimeTypeMap.getFileExtensionFromUrl(url).ifNullOrEmpty { "dat" }
|
||||||
@@ -116,13 +116,4 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
val name = UUID.randomUUID().toString() + "." + ext
|
val name = UUID.randomUUID().toString() + "." + ext
|
||||||
return File(rootDir, name)
|
return File(rootDir, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
|
||||||
|
|
||||||
val SIZE_MIN
|
|
||||||
get() = FileSize.MEGABYTES.convert(20, FileSize.BYTES)
|
|
||||||
|
|
||||||
val SIZE_DEFAULT
|
|
||||||
get() = FileSize.MEGABYTES.convert(200, FileSize.BYTES)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -11,8 +11,8 @@ import kotlinx.coroutines.runInterruptible
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
import okio.sink
|
import okio.sink
|
||||||
import okio.source
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.openSource
|
||||||
import org.koitharu.kotatsu.core.util.ext.resolveName
|
import org.koitharu.kotatsu.core.util.ext.resolveName
|
||||||
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||||
@@ -51,12 +51,12 @@ class SingleMangaImporter @Inject constructor(
|
|||||||
}
|
}
|
||||||
val dest = File(getOutputDir(), name)
|
val dest = File(getOutputDir(), name)
|
||||||
runInterruptible {
|
runInterruptible {
|
||||||
contentResolver.openInputStream(uri)
|
contentResolver.openSource(uri)
|
||||||
}?.use { source ->
|
}.use { source ->
|
||||||
dest.sink().buffer().use { output ->
|
dest.sink().buffer().use { output ->
|
||||||
output.writeAllCancellable(source.source())
|
output.writeAllCancellable(source)
|
||||||
}
|
}
|
||||||
} ?: throw IOException("Cannot open input stream: $uri")
|
}
|
||||||
LocalMangaParser(dest).getManga(withDetails = false)
|
LocalMangaParser(dest).getManga(withDetails = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ class SingleMangaImporter @Inject constructor(
|
|||||||
docFile.copyTo(subDir)
|
docFile.copyTo(subDir)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
inputStream().source().use { input ->
|
source().use { input ->
|
||||||
File(destDir, requireName()).sink().buffer().use { output ->
|
File(destDir, requireName()).sink().buffer().use { output ->
|
||||||
output.writeAllCancellable(input)
|
output.writeAllCancellable(input)
|
||||||
}
|
}
|
||||||
@@ -92,8 +92,8 @@ class SingleMangaImporter @Inject constructor(
|
|||||||
return storageManager.getDefaultWriteableDir() ?: throw IOException("External files dir unavailable")
|
return storageManager.getDefaultWriteableDir() ?: throw IOException("External files dir unavailable")
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun DocumentFile.inputStream() = runInterruptible(Dispatchers.IO) {
|
private suspend fun DocumentFile.source() = runInterruptible(Dispatchers.IO) {
|
||||||
contentResolver.openInputStream(uri) ?: throw IOException("Cannot open input stream: $uri")
|
contentResolver.openSource(uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DocumentFile.requireName(): String {
|
private fun DocumentFile.requireName(): String {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ class LocalMangaIndex @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun upsert(manga: LocalManga) {
|
private suspend fun upsert(manga: LocalManga) {
|
||||||
mangaDataRepository.storeManga(manga.manga)
|
mangaDataRepository.storeManga(manga.manga, replaceExisting = true)
|
||||||
db.getLocalMangaIndexDao().upsert(manga.toEntity())
|
db.getLocalMangaIndexDao().upsert(manga.toEntity())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import org.koitharu.kotatsu.core.util.ShareHelper
|
|||||||
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
||||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
|
||||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||||
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||||
@@ -45,13 +44,14 @@ class LocalListFragment : MangaListFragment(), FilterCoordinator.Owner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
withArgs(1) {
|
super.onCreate(savedInstanceState)
|
||||||
putString(
|
val args = arguments ?: Bundle(1)
|
||||||
RemoteListFragment.ARG_SOURCE,
|
args.putString(
|
||||||
LocalMangaSource.name,
|
RemoteListFragment.ARG_SOURCE,
|
||||||
) // required by FilterCoordinator
|
LocalMangaSource.name,
|
||||||
}
|
) // required by FilterCoordinator
|
||||||
|
arguments = args
|
||||||
}
|
}
|
||||||
|
|
||||||
override val viewModel by viewModels<LocalListViewModel>()
|
override val viewModel by viewModels<LocalListViewModel>()
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.model.toChipModel
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||||
|
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||||
import org.koitharu.kotatsu.core.util.ext.call
|
import org.koitharu.kotatsu.core.util.ext.call
|
||||||
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
||||||
@@ -17,10 +20,13 @@ import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
|||||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||||
import org.koitharu.kotatsu.explore.domain.ExploreRepository
|
import org.koitharu.kotatsu.explore.domain.ExploreRepository
|
||||||
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
||||||
|
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||||
import org.koitharu.kotatsu.list.domain.MangaListMapper
|
import org.koitharu.kotatsu.list.domain.MangaListMapper
|
||||||
|
import org.koitharu.kotatsu.list.domain.QuickFilterListener
|
||||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.list.ui.model.MangaListModel
|
import org.koitharu.kotatsu.list.ui.model.MangaListModel
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.QuickFilter
|
||||||
import org.koitharu.kotatsu.list.ui.model.TipModel
|
import org.koitharu.kotatsu.list.ui.model.TipModel
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||||
@@ -52,9 +58,10 @@ class LocalListViewModel @Inject constructor(
|
|||||||
exploreRepository = exploreRepository,
|
exploreRepository = exploreRepository,
|
||||||
sourcesRepository = sourcesRepository,
|
sourcesRepository = sourcesRepository,
|
||||||
mangaDataRepository = mangaDataRepository,
|
mangaDataRepository = mangaDataRepository,
|
||||||
), SharedPreferences.OnSharedPreferenceChangeListener {
|
), SharedPreferences.OnSharedPreferenceChangeListener, QuickFilterListener {
|
||||||
|
|
||||||
val onMangaRemoved = MutableEventFlow<Unit>()
|
val onMangaRemoved = MutableEventFlow<Unit>()
|
||||||
|
private val showInlineFilter: Boolean = savedStateHandle[AppRouter.KEY_IS_BOTTOMTAB] ?: false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
launchJob(Dispatchers.Default) {
|
launchJob(Dispatchers.Default) {
|
||||||
@@ -68,29 +75,49 @@ class LocalListViewModel @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun onBuildList(list: MutableList<ListModel>) {
|
override suspend fun onBuildList(list: MutableList<ListModel>) {
|
||||||
super.onBuildList(list)
|
super.onBuildList(list)
|
||||||
if (localStorageManager.hasExternalStoragePermission(isReadOnly = true)) {
|
if (showInlineFilter) {
|
||||||
return
|
createFilterHeader(maxCount = 16)?.let {
|
||||||
}
|
list.add(0, it)
|
||||||
for (item in list) {
|
|
||||||
if (item !is MangaListModel) {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
val file = item.manga.url.toUriOrNull()?.toFileOrNull() ?: continue
|
}
|
||||||
if (localStorageManager.isOnExternalStorage(file)) {
|
if (!localStorageManager.hasExternalStoragePermission(isReadOnly = true)) {
|
||||||
val tip = TipModel(
|
for (item in list) {
|
||||||
key = "permission",
|
if (item !is MangaListModel) {
|
||||||
title = R.string.external_storage,
|
continue
|
||||||
text = R.string.missing_storage_permission,
|
}
|
||||||
icon = R.drawable.ic_storage,
|
val file = item.manga.url.toUriOrNull()?.toFileOrNull() ?: continue
|
||||||
primaryButtonText = R.string.fix,
|
if (localStorageManager.isOnExternalStorage(file)) {
|
||||||
secondaryButtonText = R.string.settings,
|
val tip = TipModel(
|
||||||
)
|
key = "permission",
|
||||||
list.add(0, tip)
|
title = R.string.external_storage,
|
||||||
return
|
text = R.string.missing_storage_permission,
|
||||||
|
icon = R.drawable.ic_storage,
|
||||||
|
primaryButtonText = R.string.fix,
|
||||||
|
secondaryButtonText = R.string.settings,
|
||||||
|
)
|
||||||
|
list.add(0, tip)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setFilterOption(option: ListFilterOption, isApplied: Boolean) {
|
||||||
|
if (option is ListFilterOption.Tag) {
|
||||||
|
filterCoordinator.toggleTag(option.tag, isApplied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toggleFilterOption(option: ListFilterOption) {
|
||||||
|
if (option is ListFilterOption.Tag) {
|
||||||
|
val tag = option.tag
|
||||||
|
val isSelected = tag in filterCoordinator.snapshot().listFilter.tags
|
||||||
|
filterCoordinator.toggleTag(option.tag, !isSelected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearFilter() = filterCoordinator.reset()
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
settings.unsubscribe(this)
|
settings.unsubscribe(this)
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
@@ -125,4 +152,26 @@ class LocalListViewModel @Inject constructor(
|
|||||||
actionStringRes = R.string._import,
|
actionStringRes = R.string._import,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun createFilterHeader(maxCount: Int): QuickFilter? {
|
||||||
|
val appliedTags = filterCoordinator.snapshot().listFilter.tags
|
||||||
|
val availableTags = repository.getFilterOptions().availableTags
|
||||||
|
if (appliedTags.isEmpty() && availableTags.size < 3) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val result = ArrayList<ChipsView.ChipModel>(minOf(availableTags.size, maxCount))
|
||||||
|
appliedTags.mapTo(result) { tag ->
|
||||||
|
ListFilterOption.Tag(tag).toChipModel(isChecked = true)
|
||||||
|
}
|
||||||
|
for (tag in availableTags) {
|
||||||
|
if (result.size >= maxCount) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (tag in appliedTags) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.add(ListFilterOption.Tag(tag).toChipModel(isChecked = false))
|
||||||
|
}
|
||||||
|
return QuickFilter(result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
package org.koitharu.kotatsu.local.ui
|
package org.koitharu.kotatsu.local.ui
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.core.app.NotificationChannelCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
import androidx.hilt.work.HiltWorker
|
import androidx.hilt.work.HiltWorker
|
||||||
import androidx.work.BackoffPolicy
|
import androidx.work.BackoffPolicy
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.ExistingWorkPolicy
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.ForegroundInfo
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.OutOfQuotaPolicy
|
import androidx.work.OutOfQuotaPolicy
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
@@ -12,6 +21,8 @@ import androidx.work.WorkerParameters
|
|||||||
import androidx.work.await
|
import androidx.work.await
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||||
@@ -40,9 +51,63 @@ class LocalStorageCleanupWorker @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||||
|
val title = applicationContext.getString(R.string.local_storage_cleanup)
|
||||||
|
val channel = NotificationChannelCompat.Builder(WORKER_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||||
|
.setName(title)
|
||||||
|
.setShowBadge(true)
|
||||||
|
.setVibrationEnabled(false)
|
||||||
|
.setSound(null, null)
|
||||||
|
.setLightsEnabled(true)
|
||||||
|
.build()
|
||||||
|
NotificationManagerCompat.from(applicationContext).createNotificationChannel(channel)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, WORKER_CHANNEL_ID)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentIntent(
|
||||||
|
PendingIntentCompat.getActivity(
|
||||||
|
applicationContext,
|
||||||
|
0,
|
||||||
|
AppRouter.suggestionsSettingsIntent(applicationContext),
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setOngoing(false)
|
||||||
|
.setSilent(true)
|
||||||
|
.setProgress(0, 0, true)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val actionIntent = PendingIntentCompat.getActivity(
|
||||||
|
applicationContext, SETTINGS_ACTION_CODE,
|
||||||
|
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
|
||||||
|
.putExtra(Settings.EXTRA_APP_PACKAGE, applicationContext.packageName)
|
||||||
|
.putExtra(Settings.EXTRA_CHANNEL_ID, WORKER_CHANNEL_ID),
|
||||||
|
0, false,
|
||||||
|
)
|
||||||
|
notification.addAction(
|
||||||
|
R.drawable.ic_settings,
|
||||||
|
applicationContext.getString(R.string.notifications_settings),
|
||||||
|
actionIntent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
ForegroundInfo(WORKER_NOTIFICATION_ID, notification.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
|
} else {
|
||||||
|
ForegroundInfo(WORKER_NOTIFICATION_ID, notification.build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private const val TAG = "cleanup"
|
private const val TAG = "cleanup"
|
||||||
|
private const val WORKER_CHANNEL_ID = "storage_cleanup"
|
||||||
|
private const val WORKER_NOTIFICATION_ID = 32
|
||||||
|
private const val SETTINGS_ACTION_CODE = 6
|
||||||
|
|
||||||
suspend fun enqueue(context: Context) {
|
suspend fun enqueue(context: Context) {
|
||||||
val request = OneTimeWorkRequestBuilder<LocalStorageCleanupWorker>()
|
val request = OneTimeWorkRequestBuilder<LocalStorageCleanupWorker>()
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class CoverRestoreInterceptor @Inject constructor(
|
|||||||
val repo = repositoryFactory.create(manga.source)
|
val repo = repositoryFactory.create(manga.source)
|
||||||
val fixed = repo.find(manga) ?: return false
|
val fixed = repo.find(manga) ?: return false
|
||||||
return if (fixed != manga) {
|
return if (fixed != manga) {
|
||||||
dataRepository.storeManga(fixed)
|
dataRepository.storeManga(fixed, replaceExisting = true)
|
||||||
fixed.coverUrl != manga.coverUrl
|
fixed.coverUrl != manga.coverUrl
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ package org.koitharu.kotatsu.main.domain
|
|||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import org.koitharu.kotatsu.core.model.isLocal
|
import org.koitharu.kotatsu.core.model.isLocal
|
||||||
import org.koitharu.kotatsu.core.os.NetworkState
|
import org.koitharu.kotatsu.core.os.NetworkState
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
|
||||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -17,15 +18,21 @@ class ReadingResumeEnabledUseCase @Inject constructor(
|
|||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
operator fun invoke(): Flow<Boolean> = settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) {
|
operator fun invoke(): Flow<Boolean> = settings.observe(
|
||||||
isIncognitoModeEnabled
|
AppSettings.KEY_MAIN_FAB,
|
||||||
}.flatMapLatest { incognito ->
|
AppSettings.KEY_INCOGNITO_MODE,
|
||||||
if (incognito) {
|
).map {
|
||||||
flowOf(false)
|
settings.isMainFabEnabled && !settings.isIncognitoModeEnabled
|
||||||
} else {
|
}.distinctUntilChanged()
|
||||||
combine(networkState, historyRepository.observeLast()) { isOnline, last ->
|
.flatMapLatest { isFabEnabled ->
|
||||||
last != null && (isOnline || last.isLocal)
|
if (isFabEnabled) {
|
||||||
|
observeCanResume()
|
||||||
|
} else {
|
||||||
|
flowOf(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private fun observeCanResume() = combine(networkState, historyRepository.observeLast()) { isOnline, last ->
|
||||||
|
last != null && (isOnline || last.isLocal)
|
||||||
|
}.distinctUntilChanged()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,12 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
|
|||||||
onBackPressedDispatcher.addCallback(exitCallback)
|
onBackPressedDispatcher.addCallback(exitCallback)
|
||||||
onBackPressedDispatcher.addCallback(navigationDelegate)
|
onBackPressedDispatcher.addCallback(navigationDelegate)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
val legacySearchCallback = SearchViewLegacyBackCallback(viewBinding.searchView)
|
||||||
|
viewBinding.searchView.addTransitionListener(legacySearchCallback)
|
||||||
|
onBackPressedDispatcher.addCallback(legacySearchCallback)
|
||||||
|
}
|
||||||
|
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
onFirstStart()
|
onFirstStart()
|
||||||
}
|
}
|
||||||
@@ -314,6 +320,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
|
|||||||
topFragment: Fragment? = navigationDelegate.primaryFragment,
|
topFragment: Fragment? = navigationDelegate.primaryFragment,
|
||||||
isSearchOpened: Boolean = viewBinding.searchView.isShowing,
|
isSearchOpened: Boolean = viewBinding.searchView.isShowing,
|
||||||
) {
|
) {
|
||||||
|
navigationDelegate.navRailHeader?.railFab?.isVisible = isResumeEnabled
|
||||||
val fab = viewBinding.fab ?: return
|
val fab = viewBinding.fab ?: return
|
||||||
if (isResumeEnabled && !actionModeDelegate.isActionModeStarted && !isSearchOpened && topFragment is HistoryListFragment) {
|
if (isResumeEnabled && !actionModeDelegate.isActionModeStarted && !isSearchOpened && topFragment is HistoryListFragment) {
|
||||||
if (!fab.isVisible) {
|
if (!fab.isVisible) {
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
package org.koitharu.kotatsu.main.ui
|
package org.koitharu.kotatsu.main.ui
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.Gravity
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.widget.FrameLayout
|
||||||
import androidx.activity.OnBackPressedCallback
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.annotation.IdRes
|
import androidx.annotation.IdRes
|
||||||
import androidx.core.view.isEmpty
|
import androidx.core.view.isEmpty
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.iterator
|
import androidx.core.view.iterator
|
||||||
import androidx.core.view.size
|
import androidx.core.view.size
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.FragmentManager
|
import androidx.fragment.app.FragmentManager
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
@@ -16,22 +20,20 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import com.google.android.material.navigation.NavigationBarView
|
import com.google.android.material.navigation.NavigationBarView
|
||||||
import com.google.android.material.navigationrail.NavigationRailView
|
import com.google.android.material.navigationrail.NavigationRailView
|
||||||
import com.google.android.material.transition.MaterialFadeThrough
|
import com.google.android.material.transition.MaterialFadeThrough
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.channels.trySendBlocking
|
import kotlinx.coroutines.channels.trySendBlocking
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
import kotlinx.coroutines.flow.filter
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.onStart
|
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksFragment
|
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksFragment
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.prefs.NavItem
|
import org.koitharu.kotatsu.core.prefs.NavItem
|
||||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||||
import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView
|
import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.buildBundle
|
||||||
import org.koitharu.kotatsu.core.util.ext.setContentDescriptionAndTooltip
|
import org.koitharu.kotatsu.core.util.ext.setContentDescriptionAndTooltip
|
||||||
import org.koitharu.kotatsu.core.util.ext.smoothScrollToTop
|
import org.koitharu.kotatsu.core.util.ext.smoothScrollToTop
|
||||||
import org.koitharu.kotatsu.databinding.NavigationRailFabBinding
|
import org.koitharu.kotatsu.databinding.NavigationRailFabBinding
|
||||||
@@ -56,7 +58,7 @@ class MainNavigationDelegate(
|
|||||||
NavigationBarView.OnItemReselectedListener, View.OnClickListener {
|
NavigationBarView.OnItemReselectedListener, View.OnClickListener {
|
||||||
|
|
||||||
private val listeners = LinkedList<OnFragmentChangedListener>()
|
private val listeners = LinkedList<OnFragmentChangedListener>()
|
||||||
private val navRailHeader = (navBar as? NavigationRailView)?.headerView?.let {
|
val navRailHeader = (navBar as? NavigationRailView)?.headerView?.let {
|
||||||
NavigationRailFabBinding.bind(it)
|
NavigationRailFabBinding.bind(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +69,9 @@ class MainNavigationDelegate(
|
|||||||
navBar.setOnItemSelectedListener(this)
|
navBar.setOnItemSelectedListener(this)
|
||||||
navBar.setOnItemReselectedListener(this)
|
navBar.setOnItemReselectedListener(this)
|
||||||
navRailHeader?.run {
|
navRailHeader?.run {
|
||||||
|
root.updateLayoutParams<FrameLayout.LayoutParams> {
|
||||||
|
gravity = Gravity.TOP or Gravity.CENTER
|
||||||
|
}
|
||||||
val horizontalPadding = (navBar as NavigationRailView).itemActiveIndicatorMarginHorizontal
|
val horizontalPadding = (navBar as NavigationRailView).itemActiveIndicatorMarginHorizontal
|
||||||
root.setPadding(horizontalPadding, 0, horizontalPadding, 0)
|
root.setPadding(horizontalPadding, 0, horizontalPadding, 0)
|
||||||
buttonExpand.setOnClickListener(this@MainNavigationDelegate)
|
buttonExpand.setOnClickListener(this@MainNavigationDelegate)
|
||||||
@@ -93,25 +98,7 @@ class MainNavigationDelegate(
|
|||||||
when (v.id) {
|
when (v.id) {
|
||||||
R.id.button_expand -> {
|
R.id.button_expand -> {
|
||||||
if (navBar is NavigationRailView) {
|
if (navBar is NavigationRailView) {
|
||||||
if (navBar.isExpanded) {
|
setNavbarIsExpanded(!navBar.isExpanded)
|
||||||
navBar.collapse()
|
|
||||||
navRailHeader?.run {
|
|
||||||
railFab.shrink()
|
|
||||||
buttonExpand.setImageResource(R.drawable.ic_drawer_menu)
|
|
||||||
buttonExpand.setContentDescriptionAndTooltip(R.string.expand)
|
|
||||||
val horizontalPadding = navBar.itemActiveIndicatorMarginHorizontal
|
|
||||||
root.setPadding(horizontalPadding, 0, horizontalPadding, 0)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
navBar.expand()
|
|
||||||
navRailHeader?.run {
|
|
||||||
railFab.extend()
|
|
||||||
buttonExpand.setImageResource(R.drawable.ic_drawer_menu_open)
|
|
||||||
buttonExpand.setContentDescriptionAndTooltip(R.string.collapse)
|
|
||||||
val horizontalPadding = navBar.itemActiveIndicatorExpandedMarginHorizontal
|
|
||||||
root.setPadding(horizontalPadding, 0, horizontalPadding, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,10 +219,13 @@ class MainNavigationDelegate(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val fragment = instantiateFragment(fragmentClass)
|
val fragment = instantiateFragment(fragmentClass)
|
||||||
|
val args = buildBundle(1) {
|
||||||
|
putBoolean(AppRouter.KEY_IS_BOTTOMTAB, true)
|
||||||
|
}
|
||||||
fragment.enterTransition = MaterialFadeThrough()
|
fragment.enterTransition = MaterialFadeThrough()
|
||||||
fragmentManager.beginTransaction()
|
fragmentManager.beginTransaction()
|
||||||
.setReorderingAllowed(true)
|
.setReorderingAllowed(true)
|
||||||
.replace(R.id.container, fragmentClass, null, TAG_PRIMARY)
|
.replace(R.id.container, fragmentClass, args, TAG_PRIMARY)
|
||||||
.runOnCommit { onFragmentChanged(fragment, fromUser = true) }
|
.runOnCommit { onFragmentChanged(fragment, fromUser = true) }
|
||||||
.commit()
|
.commit()
|
||||||
return true
|
return true
|
||||||
@@ -267,12 +257,7 @@ class MainNavigationDelegate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun observeSettings(lifecycleOwner: LifecycleOwner) {
|
private fun observeSettings(lifecycleOwner: LifecycleOwner) {
|
||||||
settings.observe()
|
settings.observe(AppSettings.KEY_TRACKER_ENABLED, AppSettings.KEY_SUGGESTIONS, AppSettings.KEY_NAV_LABELS)
|
||||||
.filter { x ->
|
|
||||||
x == AppSettings.KEY_TRACKER_ENABLED || x == AppSettings.KEY_SUGGESTIONS || x == AppSettings.KEY_NAV_LABELS
|
|
||||||
}
|
|
||||||
.onStart { emit("") }
|
|
||||||
.flowOn(Dispatchers.IO)
|
|
||||||
.onEach {
|
.onEach {
|
||||||
setItemVisibility(R.id.nav_suggestions, settings.isSuggestionsEnabled)
|
setItemVisibility(R.id.nav_suggestions, settings.isSuggestionsEnabled)
|
||||||
setItemVisibility(R.id.nav_feed, settings.isTrackerEnabled)
|
setItemVisibility(R.id.nav_feed, settings.isTrackerEnabled)
|
||||||
@@ -298,6 +283,10 @@ class MainNavigationDelegate(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
navRailHeader?.buttonExpand?.isVisible = value
|
||||||
|
if (!value) {
|
||||||
|
setNavbarIsExpanded(false)
|
||||||
|
}
|
||||||
navBar.labelVisibilityMode = if (value) {
|
navBar.labelVisibilityMode = if (value) {
|
||||||
NavigationBarView.LABEL_VISIBILITY_LABELED
|
NavigationBarView.LABEL_VISIBILITY_LABELED
|
||||||
} else {
|
} else {
|
||||||
@@ -305,6 +294,37 @@ class MainNavigationDelegate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setNavbarIsExpanded(value: Boolean) {
|
||||||
|
if (navBar !is NavigationRailView) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (value) {
|
||||||
|
navBar.expand()
|
||||||
|
navRailHeader?.run {
|
||||||
|
root.updateLayoutParams<FrameLayout.LayoutParams> {
|
||||||
|
gravity = Gravity.TOP or Gravity.START
|
||||||
|
}
|
||||||
|
railFab.extend()
|
||||||
|
buttonExpand.setImageResource(R.drawable.ic_drawer_menu_open)
|
||||||
|
buttonExpand.setContentDescriptionAndTooltip(R.string.collapse)
|
||||||
|
val horizontalPadding = navBar.itemActiveIndicatorExpandedMarginHorizontal
|
||||||
|
root.setPadding(horizontalPadding, 0, horizontalPadding, 0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navBar.collapse()
|
||||||
|
navRailHeader?.run {
|
||||||
|
root.updateLayoutParams<FrameLayout.LayoutParams> {
|
||||||
|
gravity = Gravity.TOP or Gravity.CENTER
|
||||||
|
}
|
||||||
|
railFab.shrink()
|
||||||
|
buttonExpand.setImageResource(R.drawable.ic_drawer_menu)
|
||||||
|
buttonExpand.setContentDescriptionAndTooltip(R.string.expand)
|
||||||
|
val horizontalPadding = navBar.itemActiveIndicatorMarginHorizontal
|
||||||
|
root.setPadding(horizontalPadding, 0, horizontalPadding, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun interface OnFragmentChangedListener {
|
fun interface OnFragmentChangedListener {
|
||||||
|
|
||||||
fun onFragmentChanged(fragment: Fragment, fromUser: Boolean)
|
fun onFragmentChanged(fragment: Fragment, fromUser: Boolean)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package org.koitharu.kotatsu.main.ui
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
|
import androidx.annotation.DeprecatedSinceApi
|
||||||
|
import com.google.android.material.search.SearchView
|
||||||
|
|
||||||
|
@DeprecatedSinceApi(Build.VERSION_CODES.TIRAMISU)
|
||||||
|
class SearchViewLegacyBackCallback(
|
||||||
|
private val searchView: SearchView
|
||||||
|
) : OnBackPressedCallback(searchView.isShowing), SearchView.TransitionListener {
|
||||||
|
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
searchView.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStateChanged(
|
||||||
|
searchView: SearchView,
|
||||||
|
previousState: SearchView.TransitionState,
|
||||||
|
newState: SearchView.TransitionState
|
||||||
|
) {
|
||||||
|
isEnabled = newState >= SearchView.TransitionState.SHOWING
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import androidx.fragment.app.viewModels
|
|||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.list.ui.model.MangaListModel
|
||||||
import org.koitharu.kotatsu.picker.ui.PageImagePickActivity
|
import org.koitharu.kotatsu.picker.ui.PageImagePickActivity
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -17,8 +17,8 @@ class MangaPickerFragment : MangaListFragment() {
|
|||||||
|
|
||||||
override fun onScrolledToEnd() = Unit
|
override fun onScrolledToEnd() = Unit
|
||||||
|
|
||||||
override fun onItemClick(item: Manga, view: View) {
|
override fun onItemClick(item: MangaListModel, view: View) {
|
||||||
(activity as PageImagePickActivity).onMangaPicked(item)
|
(activity as PageImagePickActivity).onMangaPicked(item.manga)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
@@ -26,7 +26,7 @@ class MangaPickerFragment : MangaListFragment() {
|
|||||||
activity?.setTitle(R.string.pick_manga_page)
|
activity?.setTitle(R.string.pick_manga_page)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemLongClick(item: Manga, view: View): Boolean = false
|
override fun onItemLongClick(item: MangaListModel, view: View): Boolean = false
|
||||||
|
|
||||||
override fun onItemContextClick(item: Manga, view: View): Boolean = false
|
override fun onItemContextClick(item: MangaListModel, view: View): Boolean = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import org.koitharu.kotatsu.core.util.ext.getEnumValue
|
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.putAll
|
||||||
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
||||||
import org.koitharu.kotatsu.reader.domain.TapGridArea
|
import org.koitharu.kotatsu.reader.domain.TapGridArea
|
||||||
@@ -44,7 +44,7 @@ class TapGridSettings @Inject constructor(@ApplicationContext context: Context)
|
|||||||
initPrefs(withDefaultValues = false)
|
initPrefs(withDefaultValues = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observe() = prefs.observe().flowOn(Dispatchers.IO)
|
fun observeChanges() = prefs.observeChanges().flowOn(Dispatchers.IO)
|
||||||
|
|
||||||
fun getAllValues(): Map<String, *> = prefs.all
|
fun getAllValues(): Map<String, *> = prefs.all
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user