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