Compare commits

...

53 Commits
v9.0.1 ... v9.1

Author SHA1 Message Date
Дмитро Крук
a090965a2d Translated using Weblate (Ukrainian)
Currently translated at 99.1% (860 of 867 strings)

Co-authored-by: Дмитро Крук <dimka89050@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
yunyi
1e376754bc Added translation using Weblate (Baoulé)
Co-authored-by: yunyi <1967158164@qq.com>
2025-08-04 16:12:21 +03:00
Hidayat
2cdbe52056 Translated using Weblate (Indonesian)
Currently translated at 98.7% (856 of 867 strings)

Co-authored-by: Hidayat <elbert.herry11@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
fadom06
1e09ac3ecb Translated using Weblate (German)
Currently translated at 74.9% (650 of 867 strings)

Translated using Weblate (German)

Currently translated at 74.9% (650 of 867 strings)

Co-authored-by: fadom06 <fadom06@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Draken
acc76c931a Translated using Weblate (Vietnamese)
Currently translated at 100.0% (867 of 867 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (864 of 864 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Infy's Tagalog Translations
59c12d35c1 Translated using Weblate (Filipino)
Currently translated at 99.3% (861 of 867 strings)

Translated using Weblate (Filipino)

Currently translated at 99.1% (857 of 864 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
周笑然
0e3cad1af1 Added translation using Weblate (Cantonese (Traditional Han script))
Added translation using Weblate (Cantonese (Traditional Han script))

Co-authored-by: 周笑然 <3140609186@qq.com>
2025-08-04 16:12:21 +03:00
Reptalica
ba8766b32d Translated using Weblate (Vietnamese)
Currently translated at 100.0% (863 of 863 strings)

Co-authored-by: Reptalica <reptalica20@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Dragibus Noir
35421cb71e Translated using Weblate (French)
Currently translated at 100.0% (867 of 867 strings)

Translated using Weblate (French)

Currently translated at 100.0% (863 of 863 strings)

Co-authored-by: Dragibus Noir <big.confetti700@aleeas.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
aicha roun souleiman
8cecd9a0e2 Translated using Weblate (French)
Currently translated at 100.0% (863 of 863 strings)

Co-authored-by: aicha roun souleiman <louqman078@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Nicola Bortoletto
523057f3e1 Translated using Weblate (Italian)
Currently translated at 99.8% (866 of 867 strings)

Translated using Weblate (Italian)

Currently translated at 99.7% (861 of 863 strings)

Translated using Weblate (Italian)

Currently translated at 98.3% (849 of 863 strings)

Translated using Weblate (Italian)

Currently translated at 97.9% (844 of 862 strings)

Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Frosted
337d196bc3 Translated using Weblate (Turkish)
Currently translated at 100.0% (867 of 867 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (867 of 867 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (863 of 863 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (862 of 862 strings)

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Hosted Weblate
c3b4c032bb Update translation files
Updated by "Remove blank strings" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
zmni
4590c753ed Translated using Weblate (Indonesian)
Currently translated at 99.8% (848 of 849 strings)

Co-authored-by: zmni <zmni@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Dragibus Noir
9733101f0c Translated using Weblate (French)
Currently translated at 100.0% (862 of 862 strings)

Translated using Weblate (French)

Currently translated at 100.0% (849 of 849 strings)

Co-authored-by: Dragibus Noir <big.confetti700@aleeas.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Robert Broketa
8cd71cc98d Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (849 of 849 strings)

Co-authored-by: Robert Broketa <robert@broketa.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Kanta Sekiguchi
42748d9c98 Translated using Weblate (Japanese)
Currently translated at 54.6% (464 of 849 strings)

Co-authored-by: Kanta Sekiguchi <kanta.sekiguchi360@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Bai
8043574314 Translated using Weblate (Turkish)
Currently translated at 100.0% (849 of 849 strings)

Co-authored-by: Bai <bai@baturax.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Shayan
44d1fdb9d3 Translated using Weblate (Persian)
Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Persian)

Currently translated at 32.9% (280 of 849 strings)

Co-authored-by: Shayan <shayans31516@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fa/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2025-08-04 16:12:21 +03:00
gekka
bc7054de4a Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.5% (863 of 867 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.5% (860 of 864 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.5% (859 of 863 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.5% (858 of 862 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.4% (843 of 848 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.4% (843 of 848 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Halbast Abdullah
4971e8ab0f Translated using Weblate (Kurdish (Central))
Currently translated at 2.5% (22 of 848 strings)

Translated using Weblate (Kurdish (Central))

Currently translated at 66.6% (6 of 9 strings)

Added translation using Weblate (Kurdish (Central))

Added translation using Weblate (Kurdish (Central))

Co-authored-by: Halbast Abdullah <halbastabdullah7@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ckb/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ckb/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2025-08-04 16:12:21 +03:00
Anon
df038b1edb Translated using Weblate (Serbian)
Currently translated at 99.5% (841 of 845 strings)

Translated using Weblate (Serbian)

Currently translated at 99.5% (841 of 845 strings)

Translated using Weblate (Serbian)

Currently translated at 99.5% (841 of 845 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Juan Rubin
7e7aabc1d1 Translated using Weblate (Portuguese)
Currently translated at 100.0% (845 of 845 strings)

Co-authored-by: Juan Rubin <juancrubin08@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Akhil Raj
9605ff89fb Translated using Weblate (Malayalam)
Currently translated at 4.7% (40 of 845 strings)

Translated using Weblate (Malayalam)

Currently translated at 4.7% (40 of 845 strings)

Translated using Weblate (Malayalam)

Currently translated at 3.5% (30 of 845 strings)

Translated using Weblate (Malayalam)

Currently translated at 3.5% (30 of 845 strings)

Co-authored-by: Akhil Raj <akhilakae07@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ml/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Draken
4ed177d29f Translated using Weblate (Vietnamese)
Currently translated at 100.0% (862 of 862 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (849 of 849 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (848 of 848 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (848 of 848 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (848 of 848 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (845 of 845 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Frosted
61cefefd10 Translated using Weblate (Turkish)
Currently translated at 100.0% (845 of 845 strings)

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2025-08-04 16:12:21 +03:00
Koitharu
9f965c5269 Remove Telegram bot token from public source 2025-08-04 16:11:28 +03:00
Koitharu
0c713cb799 Fix inifite captcha notifications 2025-08-04 12:50:09 +03:00
Koitharu
6d3f8cbb3b Filter for Local storage tab on main screen 2025-08-04 09:57:07 +03:00
Koitharu
05739bb5b3 Proper handling network unavailable error for images 2025-07-30 16:22:21 +03:00
Koitharu
47f0bbee17 Disk cache for favicons 2025-07-30 15:52:09 +03:00
Koitharu
dd77926dcb Improve sources settings 2025-07-30 14:50:45 +03:00
Koitharu
1b76f21507 Show reason why manga has no chapters 2025-07-30 14:06:45 +03:00
Koitharu
fe21af5443 Update parsers 2025-07-29 16:22:04 +03:00
Koitharu
0b0373021e Fix loading local manga 2025-07-26 19:28:32 +03:00
Koitharu
d641e7933d Option to show only downloaded chapters 2025-07-25 13:43:01 +03:00
Koitharu
d8efe374a8 Experimental: improve manga loading in reader 2025-07-24 15:20:42 +03:00
Koitharu
506a8b6e90 UI fixes 2025-07-23 12:08:27 +03:00
Koitharu
d81173bf76 Merge branch 'feature/discord_rpc' into devel 2025-07-22 16:42:35 +03:00
Koitharu
896452a096 Discord RPC improvements 2025-07-22 13:05:27 +03:00
Daniil Zhuravlev
35aa4d5e8f ci: add a site update trigger when the application is released 2025-07-21 08:59:34 +03:00
Koitharu
4d4c9c7a48 Discord RPC 2025-07-20 15:18:11 +03:00
Koitharu
b667e32598 Update parsers 2025-07-20 08:13:28 +03:00
Koitharu
c987fc234b Add LeakCanary to nighly builds 2025-07-17 20:42:12 +03:00
Koitharu
8142a6811b Add option to hide fab (close #1466) 2025-07-16 20:05:00 +03:00
Koitharu
3e36e1e11c Debug menu for debug builds 2025-07-16 20:05:00 +03:00
Koitharu
30aaca6341 Merge pull request #1497 from dragonx943/patch-1 2025-07-13 18:48:46 +03:00
Draken
43b34a7bca Fix gradle checksum 2025-07-13 21:04:31 +07:00
Koitharu
b23008d0ae Update Miku theme #1490 2025-07-13 11:23:15 +03:00
Koitharu
5a368b27bb Fix override applying 2025-07-13 11:07:00 +03:00
Koitharu
fe3f95d160 Cache custom covers (close #1492) 2025-07-13 10:04:18 +03:00
Koitharu
de1a297338 Fix downloading edited manga (close #1493) 2025-07-13 09:41:04 +03:00
Koitharu
d6350afe3a Upgrade target sdk 2025-07-13 09:35:24 +03:00
160 changed files with 2479 additions and 819 deletions

View 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
View File

@@ -10,6 +10,6 @@
</option>
</component>
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -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 = 1023
versionName = '9.0.1'
targetSdk = 36
versionCode = 1024
versionName = '9.1'
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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

@@ -6,10 +6,10 @@ import android.os.Bundle
import android.text.format.DateUtils
import android.view.View
import androidx.activity.result.ActivityResultCallback
import androidx.core.content.ContextCompat
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
@@ -38,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)
}
@@ -86,9 +87,7 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
else -> path
}
preference.icon = if (path == null) {
ContextCompat.getDrawable(preference.context, R.drawable.ic_alert_outline)?.also {
it.setTint(ContextCompat.getColor(preference.context, R.color.warning))
}
getWarningIcon()
} else {
null
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -104,14 +104,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())
}
@@ -225,6 +225,7 @@ class CaptchaHandler @Inject constructor(
.data(source.faviconUri())
.allowHardware(false)
.allowConversionToBitmap(true)
.ignoreCaptchaErrors()
.mangaSourceExtra(source)
.size(context.resources.getNotificationIconSize())
.scale(Scale.FILL)

View File

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

View File

@@ -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
}
@@ -745,6 +749,10 @@ class AppRouter private constructor(
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)
@@ -804,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"
@@ -827,6 +836,7 @@ 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"

View File

@@ -173,6 +173,7 @@ class AppShortcutManager @Inject constructor(
coil.execute(
ImageRequest.Builder(context)
.data(source.faviconUri())
.mangaSourceExtra(source)
.size(iconSize)
.scale(Scale.FIT)
.build(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,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()
@@ -81,15 +81,28 @@ abstract class BaseViewModel : ViewModel() {
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
private fun createErrorHandler() = CoroutineExceptionHandler { coroutineContext, throwable ->
throwable.printStackTraceDebug()
if (coroutineContext[SkipErrors.key] == null && 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)
}
}
}
}

View File

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

View File

@@ -1,53 +1,30 @@
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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,91 +41,114 @@ 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,
),
)
if (manga.isLocal) {
val details = getDetails(manga, force)
send(
loadLocal(manga, override, force)
} else {
loadRemote(manga, override, force)
}
}.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
}
val remoteManga = localMangaRepository.getRemoteManga(manga)
if (remoteManga == null) {
emit(
MangaDetails(
manga = details,
manga = localDetails,
localManga = null,
override = override,
description = details.description?.parseAsHtml(withImages = false)?.trim(),
description = localDetails.description?.parseAsHtml(withImages = true),
isLoaded = true,
),
)
return@channelFlow
}
val local = async {
localMangaRepository.findSavedManga(manga)
}
if (!force && networkState.isOfflineOrRestricted()) {
// try to avoid loading if has saved manga
val localManga = local.await()
if (localManga != null) {
send(
MangaDetails(
manga = manga,
localManga = localManga,
override = override,
description = manga.description?.parseAsHtml(withImages = true)?.trim(),
isLoaded = true,
),
)
return@channelFlow
} 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)
}
}
try {
val details = getDetails(manga, force)
launch { mangaDataRepository.updateChapters(details) }
launch { updateTracker(details) }
send(
}
/**
* 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 = details,
localManga = local.peek(),
manga = manga,
localManga = localManga,
override = override,
description = details.description?.parseAsHtml(withImages = false)?.trim(),
description = localManga.manga.description?.parseAsHtml(withImages = true),
isLoaded = false,
),
)
send(
MangaDetails(
manga = details,
localManga = local.await(),
override = override,
description = details.description?.parseAsHtml(withImages = true)?.trim(),
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)
}
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 {
@@ -140,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>()
@@ -162,10 +184,4 @@ class DetailsLoadUseCase @Inject constructor(
}
return spannable
}
private suspend fun updateTracker(details: Manga) = runCatchingCancellable {
newChaptersUseCaseProvider.get()(details)
}.onFailure { e ->
e.printStackTraceDebug()
}
}

View File

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

View File

@@ -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
@@ -261,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,
)
}
@@ -390,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -63,7 +63,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

View File

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

View File

@@ -10,17 +10,17 @@ 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 ->

View File

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

View File

@@ -1,6 +1,5 @@
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
@@ -10,15 +9,15 @@ 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 {
itemView.setTooltipCompat(item.getSummary(context))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -320,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) {

View File

@@ -7,6 +7,7 @@ import android.view.View
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.fragment.app.Fragment
@@ -16,22 +17,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 +55,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)
}
@@ -93,25 +92,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 +213,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 +251,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 +277,10 @@ class MainNavigationDelegate(
},
)
}
navRailHeader?.buttonExpand?.isVisible = value
if (!value) {
setNavbarIsExpanded(false)
}
navBar.labelVisibilityMode = if (value) {
NavigationBarView.LABEL_VISIBILITY_LABELED
} else {
@@ -305,6 +288,31 @@ class MainNavigationDelegate(
}
}
private fun setNavbarIsExpanded(value: Boolean) {
if (navBar !is NavigationRailView) {
return
}
if (value) {
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)
}
} else {
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)
}
}
}
fun interface OnFragmentChangedListener {
fun onFragmentChanged(fragment: Fragment, fromUser: Boolean)

View File

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

View File

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

View File

@@ -65,7 +65,8 @@ import org.koitharu.kotatsu.core.util.ext.use
import org.koitharu.kotatsu.core.util.ext.withProgress
import org.koitharu.kotatsu.core.util.progress.ProgressDeferred
import org.koitharu.kotatsu.download.ui.worker.DownloadSlowdownDispatcher
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.LocalStorageCache
import org.koitharu.kotatsu.local.data.PageCache
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.requireBody
@@ -84,7 +85,7 @@ class PageLoader @Inject constructor(
@LocalizedAppContext private val context: Context,
lifecycle: ActivityRetainedLifecycle,
@MangaHttpClient private val okHttp: OkHttpClient,
private val cache: PagesCache,
@PageCache private val cache: LocalStorageCache,
private val coil: ImageLoader,
private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory,
@@ -196,7 +197,7 @@ class PageLoader @Inject constructor(
}
}
}.use { image ->
cache.put(uri.toString(), image).toUri()
cache.set(uri.toString(), image).toUri()
}
} else {
val file = uri.toFile()
@@ -300,7 +301,7 @@ class PageLoader @Inject constructor(
val request = createPageRequest(pageUrl, page.source)
imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response ->
response.requireBody().withProgress(progress).use {
cache.put(pageUrl, it.source(), it.contentType()?.toMimeType())
cache.set(pageUrl, it.source(), it.contentType()?.toMimeType())
}
}.toUri()
}

View File

@@ -12,7 +12,6 @@ import android.widget.Button
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.annotation.AttrRes
import androidx.appcompat.widget.TooltipCompat
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import com.google.android.material.slider.Slider
@@ -50,7 +49,9 @@ class ReaderActionsView @JvmOverloads constructor(
private val binding = LayoutReaderActionsBinding.inflate(LayoutInflater.from(context), this)
private val rotationObserver = object : ContentObserver(handler) {
override fun onChange(selfChange: Boolean) {
updateRotationButton()
post {
updateRotationButton()
}
}
}
private var isSliderChanged = false

View File

@@ -29,10 +29,15 @@ import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.DialogErrorObserver
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -72,7 +77,9 @@ class ReaderActivity :
ReaderControlDelegate.OnInteractionListener,
ReaderNavigationCallback,
IdlingDetector.Callback,
ZoomControl.ZoomControlListener, View.OnClickListener, ScrollTimerControlView.OnVisibilityChangeListener {
ZoomControl.ZoomControlListener,
View.OnClickListener,
ScrollTimerControlView.OnVisibilityChangeListener {
@Inject
lateinit var settings: AppSettings
@@ -131,7 +138,7 @@ class ReaderActivity :
}
}
viewModel.onError.observeEvent(
viewModel.onLoadingError.observeEvent(
this,
DialogErrorObserver(
host = viewBinding.container,
@@ -146,13 +153,24 @@ class ReaderActivity :
},
),
)
viewModel.onError.observeEvent(
this,
SnackbarErrorObserver(
host = viewBinding.container,
fragment = null,
resolver = exceptionResolver,
onResolved = null,
),
)
viewModel.readerMode.observe(this, Lifecycle.State.STARTED, this::onInitReader)
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(viewBinding.container))
viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged)
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.content.observe(this) {
onLoadingStateChanged(viewModel.isLoading.value)
}
combine(
viewModel.isLoading,
viewModel.content.map { it.pages.isNotEmpty() }.distinctUntilChanged(),
::Pair,
).flowOn(Dispatchers.Default)
.observe(this, this::onLoadingStateChanged)
viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn)
viewModel.isInfoBarTransparent.observe(this) { viewBinding.infoBar.drawBackground = !it }
viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged)
@@ -190,6 +208,11 @@ class ReaderActivity :
viewModel.onPause()
}
override fun onStop() {
super.onStop()
viewModel.onStop()
}
override fun onProvideAssistContent(outContent: AssistContent) {
super.onProvideAssistContent(outContent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@@ -201,6 +224,7 @@ class ReaderActivity :
override fun onIdle() {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
viewModel.onIdle()
}
override fun onVisibilityChanged(v: View, visibility: Int) {
@@ -235,9 +259,14 @@ class ReaderActivity :
viewBinding.timerControl.onReaderModeChanged(mode)
}
private fun onLoadingStateChanged(isLoading: Boolean) {
val hasPages = viewModel.content.value.pages.isNotEmpty()
viewBinding.layoutLoading.isVisible = isLoading && !hasPages
private fun onLoadingStateChanged(value: Pair<Boolean, Boolean>) {
val (isLoading, hasPages) = value
val showLoadingLayout = isLoading && !hasPages
if (viewBinding.layoutLoading.isVisible != showLoadingLayout) {
val transition = Fade().addTarget(viewBinding.layoutLoading)
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
viewBinding.layoutLoading.isVisible = showLoadingLayout
}
if (isLoading && hasPages) {
viewBinding.toastView.show(R.string.loading_)
} else {
@@ -474,6 +503,7 @@ class ReaderActivity :
private fun updateScrollTimerButton() {
val button = viewBinding.buttonTimer ?: return
val isButtonVisible = scrollTimer.isActive.value
&& settings.isReaderAutoscrollFabVisible
&& !viewBinding.appbarTop.isVisible
&& !viewBinding.timerControl.isVisible
if (button.isVisible != isButtonVisible) {

View File

@@ -7,6 +7,7 @@ import androidx.annotation.WorkerThread
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
@@ -57,12 +58,14 @@ import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.sizeOrZero
import org.koitharu.kotatsu.reader.domain.ChaptersLoader
import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.scrobbling.discord.ui.DiscordRpc
import org.koitharu.kotatsu.stats.domain.StatsCollector
import java.time.Instant
import javax.inject.Inject
@@ -84,6 +87,7 @@ class ReaderViewModel @Inject constructor(
private val historyUpdateUseCase: HistoryUpdateUseCase,
private val detectReaderModeUseCase: DetectReaderModeUseCase,
private val statsCollector: StatsCollector,
private val discordRpc: DiscordRpc,
@LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>,
interactor: DetailsInteractor,
deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
@@ -106,13 +110,12 @@ class ReaderViewModel @Inject constructor(
private var stateChangeJob: Job? = null
init {
selectedBranch.value = savedStateHandle.get<String>(ReaderIntent.EXTRA_BRANCH)
readingState.value = savedStateHandle[ReaderIntent.EXTRA_STATE]
mangaDetails.value = intent.manga?.let { MangaDetails(it) }
}
val readerMode = MutableStateFlow<ReaderMode?>(null)
val onPageSaved = MutableEventFlow<Collection<Uri>>()
val onLoadingError = MutableEventFlow<Throwable>()
val onShowToast = MutableEventFlow<Int>()
val onAskNsfwIncognito = MutableEventFlow<Unit>()
val uiState = MutableStateFlow<ReaderUiState?>(null)
@@ -210,6 +213,14 @@ class ReaderViewModel @Inject constructor(
}
}
fun onStop() {
discordRpc.clearRpc()
}
fun onIdle() {
discordRpc.setIdle()
}
fun switchMode(newMode: ReaderMode) {
launchJob {
val manga = checkNotNull(getMangaOrNull())
@@ -383,31 +394,62 @@ class ReaderViewModel @Inject constructor(
}
private fun loadImpl() {
loadingJob = launchLoadingJob(Dispatchers.Default) {
val details = detailsLoadUseCase.invoke(intent, force = false).first { x -> x.isLoaded }
mangaDetails.value = details
chaptersLoader.init(details)
val manga = details.toManga()
// obtain state
if (readingState.value == null) {
readingState.value = getStateFromIntent(manga)
}
val mode = detectReaderModeUseCase.invoke(manga, readingState.value)
val branch = chaptersLoader.peekChapter(readingState.value?.chapterId ?: 0L)?.branch
selectedBranch.value = branch
mangaDetails.value = details.filterChapters(branch)
readerMode.value = mode
loadingJob = launchLoadingJob(Dispatchers.Default + EventExceptionHandler(onLoadingError)) {
var exception: Exception? = null
try {
detailsLoadUseCase(intent, force = false)
.collect { details ->
if (mangaDetails.value == null) {
mangaDetails.value = details
}
chaptersLoader.init(details)
val manga = details.toManga()
// obtain state
if (readingState.value == null) {
val newState = getStateFromIntent(manga)
if (newState == null) {
return@collect // manga not loaded yet if cannot get state
}
readingState.value = newState
val mode = runCatchingCancellable {
detectReaderModeUseCase(manga, newState)
}.getOrDefault(settings.defaultReaderMode)
val branch = chaptersLoader.peekChapter(newState.chapterId)?.branch
selectedBranch.value = branch
readerMode.value = mode
try {
chaptersLoader.loadSingleChapter(newState.chapterId)
} catch (e: Exception) {
readingState.value = null // try next time
exception = e.mergeWith(exception)
return@collect
}
}
mangaDetails.value = details.filterChapters(selectedBranch.value)
chaptersLoader.loadSingleChapter(requireNotNull(readingState.value).chapterId)
// save state
if (!isIncognitoMode.firstNotNull()) {
readingState.value?.let {
val percent = computePercent(it.chapterId, it.page)
historyUpdateUseCase.invoke(manga, it, percent)
}
// save state
if (!isIncognitoMode.firstNotNull()) {
readingState.value?.let {
val percent = computePercent(it.chapterId, it.page)
historyUpdateUseCase(manga, it, percent)
}
}
notifyStateChanged()
content.value = ReaderContent(chaptersLoader.snapshot(), readingState.value)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
exception = e.mergeWith(exception)
}
if (readingState.value == null) {
onLoadingError.call(
exception ?: IllegalStateException("Unable to load manga. This should never happen. Please report"),
)
} else exception?.let { e ->
// manga has been loaded but error occurred
errorEvent.call(e)
}
notifyStateChanged()
content.value = ReaderContent(chaptersLoader.snapshot(), readingState.value)
}
}
@@ -450,6 +492,7 @@ class ReaderViewModel @Inject constructor(
uiState.value = newState
if (isIncognitoMode.value == false) {
statsCollector.onStateChanged(m.id, state)
discordRpc.updateRpc(m.toManga(), newState)
}
}
@@ -502,18 +545,47 @@ class ReaderViewModel @Inject constructor(
}
}
private suspend fun getStateFromIntent(manga: Manga): ReaderState {
val history = historyRepository.getOne(manga)
val preselectedBranch = selectedBranch.value
val result = if (history != null) {
if (preselectedBranch != null && preselectedBranch != manga.findChapterById(history.chapterId)?.branch) {
private suspend fun getStateFromIntent(manga: Manga): ReaderState? {
// check if we have at least some chapters loaded
if (manga.chapters.isNullOrEmpty()) {
return null
}
// specific state is requested
val requestedState: ReaderState? = savedStateHandle[ReaderIntent.EXTRA_STATE]
if (requestedState != null) {
return if (manga.findChapterById(requestedState.chapterId) != null) {
requestedState
} else {
null
}
}
val requestedBranch: String? = savedStateHandle[ReaderIntent.EXTRA_BRANCH]
// continue reading
val history = historyRepository.getOne(manga)
if (history != null) {
val chapter = manga.findChapterById(history.chapterId) ?: return null
// specified branch is requested
return if (ReaderIntent.EXTRA_BRANCH in savedStateHandle) {
if (chapter.branch == requestedBranch) {
ReaderState(history)
} else {
ReaderState(manga, requestedBranch)
}
} else {
ReaderState(history)
}
} else {
null
}
return result ?: ReaderState(manga, preselectedBranch ?: manga.getPreferredBranch(null))
// start from beginning
val preferredBranch = requestedBranch ?: manga.getPreferredBranch(null)
return ReaderState(manga, preferredBranch)
}
private fun Exception.mergeWith(other: Exception?): Exception = if (other == null) {
this
} else {
other.addSuppressed(this)
other
}
}

View File

@@ -6,6 +6,7 @@ import android.view.LayoutInflater
import android.view.View
import android.widget.CompoundButton
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
@@ -49,8 +50,10 @@ class ScrollTimerControlView @JvmOverloads constructor(
init {
binding.switchScrollTimer.setOnCheckedChangeListener(this)
binding.sliderTimer.addOnChangeListener(this)
binding.buttonFab.setOnClickListener(this)
binding.sliderTimer.setLabelFormatter(this)
binding.buttonClose.setOnClickListener(this)
binding.buttonFab.isGone = resources.getBoolean(R.bool.is_tablet)
setPadding(0, 0, 0, context.resources.getDimensionPixelOffset(R.dimen.margin_normal))
}
@@ -73,6 +76,13 @@ class ScrollTimerControlView @JvmOverloads constructor(
)
}
}
settings.observeAsStateFlow(
scope = lifecycleOwner.lifecycleScope + Dispatchers.Default,
key = AppSettings.KEY_READER_AUTOSCROLL_FAB,
valueProducer = { isReaderAutoscrollFabVisible },
).observe(lifecycleOwner) {
binding.buttonFab.isChecked = it
}
updateDescription()
}
@@ -84,6 +94,7 @@ class ScrollTimerControlView @JvmOverloads constructor(
override fun onClick(v: View) {
when (v.id) {
R.id.button_close -> hide()
R.id.button_fab -> settings.isReaderAutoscrollFabVisible = !settings.isReaderAutoscrollFabVisible
}
}
@@ -105,7 +116,7 @@ class ScrollTimerControlView @JvmOverloads constructor(
updateDescription()
}
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
scrollTimer?.setActive(isChecked)
}

View File

@@ -122,7 +122,7 @@ data class ReaderSettings(
private suspend fun observeImpl() {
combine(
mangaId.flatMapLatest { mangaDataRepository.observeColorFilter(it) },
settings.observe().filter { x -> x == null || x in settingsKeys }.onStart { emit(null) },
settings.observeChanges().filter { x -> x == null || x in settingsKeys }.onStart { emit(null) },
) { mangaCf, settingsKey ->
ReaderSettings(settings, mangaCf)
}.collect {

View File

@@ -23,7 +23,7 @@ class WebtoonImageView @JvmOverloads constructor(
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (isDebug) {
if (isDebugDrawingEnabled) {
drawDebug(canvas)
}
}

View File

@@ -62,7 +62,7 @@ open class RemoteListViewModel @Inject constructor(
val isRandomLoading = MutableStateFlow(false)
val onOpenManga = MutableEventFlow<Manga>()
private val repository = mangaRepositoryFactory.create(source)
protected val repository = mangaRepositoryFactory.create(source)
private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val hasNextPage = MutableStateFlow(false)
private val listError = MutableStateFlow<Throwable?>(null)

View File

@@ -0,0 +1,73 @@
package org.koitharu.kotatsu.scrobbling.discord.data
import android.content.Context
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.internal.closeQuietly
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.BaseHttpClient
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.parseRaw
import javax.inject.Inject
private const val SCHEME_MP = "mp:"
@Reusable
class DiscordRepository @Inject constructor(
@ApplicationContext context: Context,
private val settings: AppSettings,
@BaseHttpClient private val httpClient: OkHttpClient,
) {
private val appId = context.getString(R.string.discord_app_id)
suspend fun getMediaProxyUrl(url: String): String? {
if (isMediaProxyUrl(url)) {
return url
}
val token = checkNotNull(settings.discordToken) {
"Discord token is missing"
}
val request = Request.Builder()
.url("https://discord.com/api/v10/applications/${appId}/external-assets")
.header(CommonHeaders.AUTHORIZATION, token)
.post("{\"urls\":[\"${url}\"]}".toRequestBody("application/json".toMediaType()))
.build()
val body = httpClient.newCall(request).await().parseRaw()
when (val json = Json.parseToJsonElement(body)) {
is JsonObject -> throw RuntimeException(json.jsonObject["message"]?.jsonPrimitive?.content)
is JsonArray -> {
val externalAssetPath = json.firstOrNull()
?.jsonObject
?.get("external_asset_path")
?.toString()
?.replace("\"", "")
return externalAssetPath?.let { SCHEME_MP + it }
}
else -> throw RuntimeException("Unexpected response: $json")
}
}
fun isMediaProxyUrl(url: String) = url.startsWith(SCHEME_MP)
suspend fun checkToken(token: String) {
val request = Request.Builder()
.url("https://discord.com/api/v10/users/@me")
.header(CommonHeaders.AUTHORIZATION, token)
.get()
.build()
httpClient.newCall(request).await().ensureSuccess().closeQuietly()
}
}

View File

@@ -0,0 +1,52 @@
package org.koitharu.kotatsu.scrobbling.discord.ui
import android.os.Bundle
import android.view.MenuItem
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.browser.BaseBrowserActivity
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaSource
import javax.inject.Inject
@AndroidEntryPoint
class DiscordAuthActivity : BaseBrowserActivity(), DiscordTokenWebClient.Callback {
@Inject
lateinit var settings: AppSettings
override fun onCreate2(
savedInstanceState: Bundle?,
source: MangaSource,
repository: ParserMangaRepository?
) {
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
viewBinding.webView.settings.userAgentString = USER_AGENT
viewBinding.webView.webViewClient = DiscordTokenWebClient(this)
if (savedInstanceState == null) {
viewBinding.webView.loadUrl(BASE_URL)
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> {
viewBinding.webView.stopLoading()
finishAfterTransition()
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onTokenObtained(token: String) {
settings.discordToken = token
setResult(RESULT_OK)
finish()
}
private companion object {
const val BASE_URL = "https://discord.com/login"
private const val USER_AGENT = "Mozilla/5.0 (Linux; Android 14; SM-S921U; Build/UP1A.231005.007) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.363"
}
}

View File

@@ -0,0 +1,170 @@
package org.koitharu.kotatsu.scrobbling.discord.ui
import android.content.Context
import androidx.annotation.AnyThread
import androidx.collection.ArrayMap
import com.my.kizzyrpc.KizzyRPC
import com.my.kizzyrpc.entities.presence.Activity
import com.my.kizzyrpc.entities.presence.Assets
import com.my.kizzyrpc.entities.presence.Metadata
import com.my.kizzyrpc.entities.presence.Timestamps
import dagger.hilt.android.ViewModelLifecycle
import dagger.hilt.android.lifecycle.RetainedLifecycle
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import okio.utf8Size
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.LocalizedAppContext
import org.koitharu.kotatsu.core.model.appUrl
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.scrobbling.discord.data.DiscordRepository
import java.util.Collections
import javax.inject.Inject
private const val STATUS_ONLINE = "online"
private const val STATUS_IDLE = "idle"
private const val BUTTON_TEXT_LIMIT = 32
@ViewModelScoped
class DiscordRpc @Inject constructor(
@LocalizedAppContext private val context: Context,
private val settings: AppSettings,
private val repository: DiscordRepository,
lifecycle: ViewModelLifecycle,
) : RetainedLifecycle.OnClearedListener {
private val coroutineScope = lifecycle.lifecycleScope + Dispatchers.Default
private val appId = context.getString(R.string.discord_app_id)
private val appName = context.getString(R.string.app_name)
private val appIcon = context.getString(R.string.app_icon_url)
private val mpCache = Collections.synchronizedMap(ArrayMap<String, String>())
private var rpc: KizzyRPC? = null
private var rpcUpdateJob: Job? = null
@Volatile
private var lastActivity: Activity? = null
init {
lifecycle.addOnClearedListener(this)
}
override fun onCleared() {
clearRpc()
}
fun clearRpc() = synchronized(this) {
rpc?.closeRPC()
rpc = null
}
fun setIdle() {
lastActivity?.let { activity ->
getRpc()?.updateRpcAsync(activity, idle = true)
}
}
@AnyThread
fun updateRpc(manga: Manga, state: ReaderUiState) {
getRpc()?.run {
if (settings.isDiscordRpcSkipNsfw && manga.isNsfw()) {
clearRpc()
return
}
updateRpcAsync(
activity = Activity(
applicationId = appId,
name = appName,
details = manga.title,
state = context.getString(R.string.chapter_d_of_d, state.chapterNumber, state.chaptersTotal),
type = 3,
timestamps = Timestamps(
start = lastActivity?.timestamps?.start ?: System.currentTimeMillis(),
),
assets = Assets(
largeImage = manga.coverUrl,
largeText = context.getString(R.string.reading_s, manga.title),
smallText = context.getString(R.string.discord_rpc_description),
smallImage = appIcon,
),
buttons = listOf(
context.getString(R.string.read_on_s, appName),
context.getString(R.string.read_on_s, manga.source.getTitle(context)),
),
metadata = Metadata(listOf(manga.appUrl.toString(), manga.publicUrl)),
),
idle = false,
)
}
}
private fun KizzyRPC.updateRpcAsync(activity: Activity, idle: Boolean) {
val prevJob = rpcUpdateJob
rpcUpdateJob = coroutineScope.launch {
prevJob?.cancelAndJoin()
val hideButtons = activity.buttons?.any { it != null && it.utf8Size() > BUTTON_TEXT_LIMIT } ?: false
val mappedActivity = activity.copy(
assets = activity.assets?.let {
it.copy(
largeImage = it.largeImage?.toMediaProxyUrl(),
smallImage = it.smallImage?.toMediaProxyUrl(),
)
},
buttons = activity.buttons.takeUnless { hideButtons },
metadata = activity.metadata.takeUnless { hideButtons },
)
lastActivity = mappedActivity
updateRPC(
activity = mappedActivity,
status = if (idle) STATUS_IDLE else STATUS_ONLINE,
since = activity.timestamps?.start ?: System.currentTimeMillis(),
)
}
}
suspend fun String.toMediaProxyUrl(): String? {
if (repository.isMediaProxyUrl(this)) {
return this
}
mpCache[this]?.let {
return it
}
return runCatchingCancellable {
repository.getMediaProxyUrl(this)
}.onSuccess { url ->
mpCache[this] = url
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
}
private fun getRpc(): KizzyRPC? {
rpc?.let {
return it
}
return synchronized(this) {
rpc?.let {
return@synchronized it
}
if (settings.isDiscordRpcEnabled) {
settings.discordToken?.let { KizzyRPC(it) }
} else {
null
}.also {
rpc = it
}
}
}
}

View File

@@ -0,0 +1,34 @@
package org.koitharu.kotatsu.scrobbling.discord.ui
import android.graphics.Bitmap
import android.webkit.WebView
import org.koitharu.kotatsu.browser.BrowserCallback
import org.koitharu.kotatsu.browser.BrowserClient
import org.koitharu.kotatsu.parsers.util.removeSurrounding
class DiscordTokenWebClient(private val callback: Callback) : BrowserClient(callback, null) {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
if (view != null) {
checkToken(view)
}
}
private fun checkToken(view: WebView) {
view.evaluateJavascript("window.localStorage.token") { result ->
val token = result
?.replace("\\\"", "")
?.removeSurrounding('"')
?.takeUnless { it == "null" }
if (!token.isNullOrEmpty()) {
callback.onTokenObtained(token)
}
}
}
interface Callback : BrowserCallback {
fun onTokenObtained(token: String)
}
}

View File

@@ -30,6 +30,7 @@ import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
@@ -115,17 +116,17 @@ class SearchActivity :
return insets.consumeAllSystemBarsInsets()
}
override fun onItemClick(item: Manga, view: View) {
override fun onItemClick(item: MangaListModel, view: View) {
if (!selectionController.onItemClick(item.id)) {
router.openDetails(item)
router.openDetails(item.toMangaWithOverride())
}
}
override fun onItemLongClick(item: Manga, view: View): Boolean {
override fun onItemLongClick(item: MangaListModel, view: View): Boolean {
return selectionController.onItemLongClick(view, item.id)
}
override fun onItemContextClick(item: Manga, view: View): Boolean {
override fun onItemContextClick(item: MangaListModel, view: View): Boolean {
return selectionController.onItemContextClick(view, item.id)
}

View File

@@ -17,8 +17,8 @@ import org.koitharu.kotatsu.databinding.ItemListGroupBinding
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.search.ui.multi.SearchResultsListModel
@SuppressLint("NotifyDataSetChanged")
@@ -26,7 +26,7 @@ fun searchResultsAD(
sharedPool: RecycledViewPool,
sizeResolver: ItemSizeResolver,
selectionDecoration: MangaSelectionDecoration,
listener: OnListItemClickListener<Manga>,
listener: OnListItemClickListener<MangaListModel>,
itemClickListener: OnListItemClickListener<SearchResultsListModel>,
) = adapterDelegateViewBinding<SearchResultsListModel, ListModel, ItemListGroupBinding>(
{ layoutInflater, parent -> ItemListGroupBinding.inflate(layoutInflater, parent, false) },

View File

@@ -25,6 +25,7 @@ class RootSettingsFragment : BasePreferenceFragment(0) {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_root)
addPreferencesFromResource(R.xml.pref_root_debug)
bindPreferenceSummary("appearance", R.string.theme, R.string.list_mode, R.string.language)
bindPreferenceSummary("reader", R.string.read_mode, R.string.scale_mode, R.string.switch_pages)
bindPreferenceSummary("network", R.string.proxy, R.string.dns_over_https, R.string.prefetch_content)
@@ -48,7 +49,6 @@ class RootSettingsFragment : BasePreferenceFragment(0) {
}
}
addMenuProvider(SettingsSearchMenuProvider(activityViewModel))
addMenuProvider(SettingsMenuProvider(view.context))
}
override fun setTitle(title: CharSequence?) {

View File

@@ -31,6 +31,7 @@ import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ActivitySettingsBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.settings.about.AboutSettingsFragment
import org.koitharu.kotatsu.settings.discord.DiscordSettingsFragment
import org.koitharu.kotatsu.settings.search.SettingsItem
import org.koitharu.kotatsu.settings.search.SettingsSearchFragment
import org.koitharu.kotatsu.settings.search.SettingsSearchViewModel
@@ -149,6 +150,7 @@ class SettingsActivity :
AppRouter.ACTION_TRACKER -> TrackerSettingsFragment()
AppRouter.ACTION_PERIODIC_BACKUP -> PeriodicalBackupSettingsFragment()
AppRouter.ACTION_SOURCES -> SourcesSettingsFragment()
AppRouter.ACTION_MANAGE_DISCORD -> DiscordSettingsFragment()
AppRouter.ACTION_PROXY -> ProxySettingsFragment()
AppRouter.ACTION_MANAGE_DOWNLOADS -> DownloadsSettingsFragment()
AppRouter.ACTION_SOURCE -> SourceSettingsFragment.newInstance(

View File

@@ -0,0 +1,114 @@
package org.koitharu.kotatsu.settings.discord
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.inputmethod.EditorInfo
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.viewModels
import androidx.preference.EditTextPreference
import androidx.preference.EditTextPreferenceDialogFragmentCompat
import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.scrobbling.discord.ui.DiscordAuthActivity
@AndroidEntryPoint
class DiscordSettingsFragment : BasePreferenceFragment(R.string.discord) {
private val viewModel by viewModels<DiscordSettingsViewModel>()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_discord)
findPreference<EditTextPreference>(AppSettings.Companion.KEY_DISCORD_TOKEN)?.let { pref ->
pref.dialogMessage = pref.context.getString(
R.string.discord_token_description,
pref.context.getString(R.string.sign_in),
)
pref.setOnBindEditTextListener {
it.setHint(R.string.discord_token_hint)
it.inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.tokenState.observe(viewLifecycleOwner) { (state, token) ->
bindTokenPreference(state, token)
}
}
override fun onDisplayPreferenceDialog(preference: Preference) {
if (preference is EditTextPreference && preference.key == AppSettings.Companion.KEY_DISCORD_TOKEN) {
if (parentFragmentManager.findFragmentByTag(TokenDialogFragment.Companion.DIALOG_FRAGMENT_TAG) != null) {
return
}
val f = TokenDialogFragment.newInstance(preference.key)
@Suppress("DEPRECATION")
f.setTargetFragment(this, 0)
f.show(parentFragmentManager, TokenDialogFragment.Companion.DIALOG_FRAGMENT_TAG)
return
}
super.onDisplayPreferenceDialog(preference)
}
private fun bindTokenPreference(state: TokenState, token: String?) {
val pref = findPreference<EditTextPreference>(AppSettings.Companion.KEY_DISCORD_TOKEN) ?: return
when (state) {
TokenState.EMPTY -> {
pref.icon = null
pref.setSummary(R.string.discord_token_summary)
}
TokenState.REQUIRED -> {
pref.icon = getWarningIcon()
pref.setSummary(R.string.discord_token_summary)
}
TokenState.INVALID -> {
pref.icon = getWarningIcon()
pref.summary = getString(R.string.invalid_token, token)
}
TokenState.VALID -> {
pref.icon = null
pref.summary = token
}
TokenState.CHECKING -> {
pref.icon = null
pref.setSummary(R.string.loading_)
}
}
}
class TokenDialogFragment : EditTextPreferenceDialogFragmentCompat() {
override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) {
super.onPrepareDialogBuilder(builder)
builder.setNeutralButton(R.string.sign_in) { _, _ ->
openSignIn()
}
}
private fun openSignIn() {
activity?.run {
startActivity(Intent(this, DiscordAuthActivity::class.java))
}
}
companion object {
const val DIALOG_FRAGMENT_TAG: String = "androidx.preference.PreferenceFragment.DIALOG"
fun newInstance(key: String) = TokenDialogFragment().withArgs(1) {
putString(ARG_KEY, key)
}
}
}
}

View File

@@ -0,0 +1,67 @@
package org.koitharu.kotatsu.settings.discord
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.isNetworkError
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.discord.data.DiscordRepository
import javax.inject.Inject
@HiltViewModel
class DiscordSettingsViewModel @Inject constructor(
private val settings: AppSettings,
private val repository: DiscordRepository,
) : BaseViewModel() {
val tokenState: StateFlow<Pair<TokenState, String?>> = settings.observe(
AppSettings.KEY_DISCORD_RPC,
AppSettings.KEY_DISCORD_TOKEN,
).flatMapLatest {
checkToken()
}.stateIn(
viewModelScope + Dispatchers.Default,
SharingStarted.Eagerly,
TokenState.CHECKING to settings.discordToken,
)
private suspend fun checkToken(): Flow<Pair<TokenState, String?>> = flow {
val token = settings.discordToken
if (!settings.isDiscordRpcEnabled) {
emit(
if (token == null) {
TokenState.EMPTY to null
} else {
TokenState.VALID to token
},
)
return@flow
}
if (token == null) {
emit(TokenState.REQUIRED to null)
return@flow
}
emit(TokenState.CHECKING to token)
if (validateToken(token)) {
emit(TokenState.VALID to token)
} else {
emit(TokenState.INVALID to token)
}
}
private suspend fun validateToken(token: String) = runCatchingCancellable {
repository.checkToken(token)
}.fold(
onSuccess = { true },
onFailure = { it.isNetworkError() },
)
}

View File

@@ -0,0 +1,6 @@
package org.koitharu.kotatsu.settings.discord
enum class TokenState {
EMPTY, REQUIRED, INVALID, VALID, CHECKING
}

View File

@@ -1,23 +1,39 @@
package org.koitharu.kotatsu.settings.override
import android.content.Context
import androidx.core.net.toUri
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
import okio.buffer
import okio.sink
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.MangaOverride
import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.isFileUri
import org.koitharu.kotatsu.core.util.ext.openSource
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.md5
import java.io.File
import javax.inject.Inject
private const val DIR_COVERS = "covers"
@HiltViewModel
class OverrideConfigViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
@ApplicationContext private val context: Context,
private val dataRepository: MangaDataRepository,
) : BaseViewModel() {
@@ -34,9 +50,12 @@ class OverrideConfigViewModel @Inject constructor(
fun save(title: String?) {
launchLoadingJob(Dispatchers.Default) {
val override = checkNotNull(data.value).second.copy(
title = title,
)
val override = checkNotNull(data.value).second.let {
it.copy(
title = title,
coverUrl = it.coverUrl?.cachedFile(),
)
}
dataRepository.setOverride(manga, override)
onSaved.call(Unit)
}
@@ -49,5 +68,33 @@ class OverrideConfigViewModel @Inject constructor(
)
}
private suspend fun String.cachedFile(): String {
val uri = toUriOrNull()
if (uri == null || uri.isFileUri()) {
return this
}
val cacheDir = context.getExternalFilesDir(DIR_COVERS) ?: return this
val cr = context.contentResolver
val ext = cr.getType(uri)?.toMimeTypeOrNull()?.let {
MimeTypes.getExtension(it)
}
val fileName = buildString {
append(this@cachedFile.md5())
if (!ext.isNullOrEmpty()) {
append('.')
append(ext)
}
}
return withContext(Dispatchers.IO) {
val dest = File(cacheDir, fileName)
cr.openSource(uri).use { source ->
dest.sink().buffer().use { sink ->
sink.writeAll(source)
}
}
dest
}.toUri().toString()
}
private fun emptyOverride() = MangaOverride(null, null, null)
}

View File

@@ -81,7 +81,7 @@ class ProtectSetupActivity :
}
}
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
viewModel.setBiometricEnabled(isChecked)
}

View File

@@ -20,7 +20,7 @@ class ReaderTapGridConfigViewModel @Inject constructor(
private val tapGridSettings: TapGridSettings,
) : BaseViewModel() {
val content = tapGridSettings.observe()
val content = tapGridSettings.observeChanges()
.onStart { emit(null) }
.map { getData() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyMap())

View File

@@ -2,7 +2,10 @@ package org.koitharu.kotatsu.settings.sources
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.viewModels
import androidx.preference.EditTextPreference
import androidx.preference.EditTextPreferenceDialogFragmentCompat
import androidx.preference.Preference
import androidx.preference.SwitchPreferenceCompat
import dagger.hilt.android.AndroidEntryPoint
@@ -112,6 +115,20 @@ class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenc
}
}
override fun onDisplayPreferenceDialog(preference: Preference) {
if (preference.key == SourceSettings.KEY_DOMAIN) {
if (parentFragmentManager.findFragmentByTag(DomainDialogFragment.DIALOG_FRAGMENT_TAG) != null) {
return
}
val f = DomainDialogFragment.newInstance(preference.key)
@Suppress("DEPRECATION")
f.setTargetFragment(this, 0)
f.show(parentFragmentManager, DomainDialogFragment.DIALOG_FRAGMENT_TAG)
return
}
super.onDisplayPreferenceDialog(preference)
}
override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean {
when (preference.key) {
KEY_ENABLE -> viewModel.setEnabled(newValue == true)
@@ -120,6 +137,32 @@ class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenc
return true
}
class DomainDialogFragment : EditTextPreferenceDialogFragmentCompat() {
override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) {
super.onPrepareDialogBuilder(builder)
builder.setNeutralButton(R.string.reset) { _, _ ->
resetValue()
}
}
private fun resetValue() {
val editTextPreference = preference as EditTextPreference
if (editTextPreference.callChangeListener("")) {
editTextPreference.text = ""
}
}
companion object {
const val DIALOG_FRAGMENT_TAG: String = "androidx.preference.PreferenceFragment.DIALOG"
fun newInstance(key: String) = DomainDialogFragment().withArgs(1) {
putString(ARG_KEY, key)
}
}
}
companion object {
private const val KEY_AUTH = "auth"

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