Compare commits

..

29 Commits

Author SHA1 Message Date
12e9fb5aab Fix download error with empty largeCoverUrl 2023-05-06 17:41:15 +03:00
5fc08d9ecb Update parsers 2023-05-06 17:34:46 +03:00
InfinityDouki56
e7eb61e3e5 Translated using Weblate (Filipino)
Currently translated at 91.4% (398 of 435 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-05-06 17:34:29 +03:00
vividly
dae3982e67 Translated using Weblate (Japanese)
Currently translated at 97.0% (422 of 435 strings)

Co-authored-by: vividly <vividly@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-05-06 17:34:29 +03:00
Макар Разин
45b2d2bebe Translated using Weblate (Belarusian)
Currently translated at 100.0% (435 of 435 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
2023-05-06 17:34:29 +03:00
77fa34835f Fix remaining incorrect sample data 2023-05-06 17:23:28 +03:00
cee68069d6 Remove sample data 2023-05-06 17:19:41 +03:00
CakesTwix
48afc8624b Translated using Weblate (Ukrainian)
Currently translated at 100.0% (435 of 435 strings)

Co-authored-by: CakesTwix <cakestwix1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-05-06 16:07:11 +03:00
b9b41ed491 Fix local manga list update on deletion 2023-05-06 16:05:39 +03:00
427272aac1 Update Material components to 1.9.0 2023-05-05 19:19:41 +03:00
78f417ebe1 Fix downloading chapters with the same names 2023-05-01 12:52:35 +03:00
3fd6bec433 Update parsers 2023-04-28 17:24:21 +03:00
262e26a0cc Fix crashes 2023-04-28 17:23:29 +03:00
1b64c2a330 Update parsers 2023-04-28 17:23:29 +03:00
5ea0ecbd12 Permormance improvements 2023-04-28 17:23:29 +03:00
Dawid Jarubas
f9a1d1617e Translated using Weblate (Polish)
Currently translated at 91.4% (398 of 435 strings)

Co-authored-by: Dawid Jarubas <jarubas.dawid@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2023-04-28 17:22:58 +03:00
gallegonovato
10d8365fc1 Translated using Weblate (Spanish)
Currently translated at 100.0% (435 of 435 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-04-28 17:22:58 +03:00
85065c57a1 Translated using Weblate (Russian)
Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (Portuguese)

Currently translated at 95.4% (415 of 435 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-04-26 15:55:27 +03:00
GpixeL
75e130b97c Translated using Weblate (Indonesian)
Currently translated at 97.6% (424 of 434 strings)

Co-authored-by: GpixeL <gamesfire313@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-04-26 15:55:27 +03:00
Luiz-bro
df99eec429 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (434 of 434 strings)

Co-authored-by: Luiz-bro <luiznneto1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2023-04-26 15:55:27 +03:00
Allan Nordhøy
3a40820991 Translated using Weblate (Norwegian Bokmål)
Currently translated at 78.1% (339 of 434 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
Translation: Kotatsu/Strings
2023-04-26 15:55:27 +03:00
gallegonovato
27bd2e74ca Translated using Weblate (Spanish)
Currently translated at 100.0% (434 of 434 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-04-26 15:55:27 +03:00
a308688a2e Misc ui fixes 2023-04-26 14:48:49 +03:00
259c845912 Fix chapters counter 2023-04-26 09:45:01 +03:00
a89ff4d15d Refactoring 2023-04-23 16:25:31 +03:00
3ed9ed8cab Fix text on sync authorization activity 2023-04-22 15:25:57 +03:00
40b9577e69 Reduce number of @Singleton instances 2023-04-22 14:56:23 +03:00
b87ae19712 Update parsers 2023-04-20 18:10:42 +03:00
1dc7e61dbd Migrate to PendingIntentCompat 2023-04-20 18:09:24 +03:00
144 changed files with 1632 additions and 1560 deletions

View File

@@ -15,8 +15,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 33 targetSdkVersion 33
versionCode 537 versionCode 540
versionName '5.0-rc2' versionName '5.0.2'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -78,17 +78,17 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:c321faeaa9') { implementation('com.github.KotatsuApp:kotatsu-parsers:96b9ac36f3') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.20' implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.21'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.0' implementation 'androidx.core:core-ktx:1.10.0'
implementation 'androidx.activity:activity-ktx:1.7.0' implementation 'androidx.activity:activity-ktx:1.7.1'
implementation 'androidx.fragment:fragment-ktx:1.5.6' implementation 'androidx.fragment:fragment-ktx:1.5.7'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-service:2.6.1' implementation 'androidx.lifecycle:lifecycle-service:2.6.1'
@@ -100,7 +100,7 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.8.1' implementation 'androidx.work:work-runtime-ktx:2.8.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.8.0' implementation 'com.google.android.material:material:1.9.0'
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1' kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 791 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

View File

@@ -1,10 +0,0 @@
Slice of Life, Mystery
Slice of Life, Mystery
Psychological, Romance, Comedy, Slice of Life, Supernatural
Sci-Fi, Comedy
Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
Adventure, Slice of Life, Mystery
Adventure, Slice of Life, Mystery

View File

@@ -1,10 +0,0 @@
Forget-me-not Vol. 1
Forget-me-not Vol. 2
La Pomme Prisoinniere
Momo Kanchou no Himitsu Kichi
Omoide Emanon
Sasurai Emanon Vol. 1
Sasurai Emanon Vol. 2
Sasurai Emanon Vol. 3
Wandering Island Vol. 1
Wandering Island Vol. 2

View File

@@ -4,6 +4,7 @@ import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.util.Size import android.util.Size
import androidx.room.withTransaction import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@@ -30,12 +31,11 @@ import java.io.File
import java.io.InputStream import java.io.InputStream
import java.util.zip.ZipFile import java.util.zip.ZipFile
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.roundToInt import kotlin.math.roundToInt
private const val MIN_WEBTOON_RATIO = 2 private const val MIN_WEBTOON_RATIO = 2
@Singleton @Reusable
class MangaDataRepository @Inject constructor( class MangaDataRepository @Inject constructor(
private val okHttpClient: OkHttpClient, private val okHttpClient: OkHttpClient,
private val db: MangaDatabase, private val db: MangaDatabase,

View File

@@ -11,9 +11,9 @@ import org.koitharu.kotatsu.utils.ext.getParcelableCompat
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
class MangaIntent private constructor( class MangaIntent private constructor(
val manga: Manga?, @JvmField val manga: Manga?,
val mangaId: Long, @JvmField val mangaId: Long,
val uri: Uri?, @JvmField val uri: Uri?,
) { ) {
constructor(intent: Intent?) : this( constructor(intent: Intent?) : this(

View File

@@ -26,34 +26,33 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.base.ui.util.BaseActivityEntryPoint import org.koitharu.kotatsu.base.ui.util.BaseActivityEntryPoint
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.base.ui.util.inject
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ext.getThemeColor import org.koitharu.kotatsu.utils.ext.getThemeColor
import javax.inject.Inject
@Suppress("LeakingThis")
abstract class BaseActivity<B : ViewBinding> : abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(), AppCompatActivity(),
WindowInsetsDelegate.WindowInsetsListener { WindowInsetsDelegate.WindowInsetsListener {
@Inject private var isAmoledTheme = false
lateinit var settings: AppSettings
protected lateinit var binding: B protected lateinit var binding: B
private set private set
@Suppress("LeakingThis") @JvmField
protected val exceptionResolver = ExceptionResolver(this) protected val exceptionResolver = ExceptionResolver(this)
@Suppress("LeakingThis") @JvmField
protected val insetsDelegate = WindowInsetsDelegate(this) protected val insetsDelegate = WindowInsetsDelegate(this)
@JvmField
val actionModeDelegate = ActionModeDelegate() val actionModeDelegate = ActionModeDelegate()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).inject(this) val settings = EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).settings
isAmoledTheme = settings.isAmoledTheme
setTheme(settings.colorScheme.styleResId) setTheme(settings.colorScheme.styleResId)
if (settings.isAmoledTheme) { if (isAmoledTheme) {
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled) setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
} }
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -62,6 +61,11 @@ abstract class BaseActivity<B : ViewBinding> :
putDataToExtras(intent) putDataToExtras(intent)
} }
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
onBackPressedDispatcher.addCallback(actionModeDelegate)
}
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent?) {
putDataToExtras(intent) putDataToExtras(intent)
super.onNewIntent(intent) super.onNewIntent(intent)
@@ -108,7 +112,7 @@ abstract class BaseActivity<B : ViewBinding> :
protected fun isDarkAmoledTheme(): Boolean { protected fun isDarkAmoledTheme(): Boolean {
val uiMode = resources.configuration.uiMode val uiMode = resources.configuration.uiMode
val isNight = uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES val isNight = uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
return isNight && settings.isAmoledTheme return isNight && isAmoledTheme
} }
@CallSuper @CallSuper

View File

@@ -10,6 +10,7 @@ import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
@Suppress("LeakingThis")
abstract class BaseFragment<B : ViewBinding> : abstract class BaseFragment<B : ViewBinding> :
Fragment(), Fragment(),
WindowInsetsDelegate.WindowInsetsListener { WindowInsetsDelegate.WindowInsetsListener {
@@ -19,10 +20,10 @@ abstract class BaseFragment<B : ViewBinding> :
protected val binding: B protected val binding: B
get() = checkNotNull(viewBinding) get() = checkNotNull(viewBinding)
@Suppress("LeakingThis") @JvmField
protected val exceptionResolver = ExceptionResolver(this) protected val exceptionResolver = ExceptionResolver(this)
@Suppress("LeakingThis") @JvmField
protected val insetsDelegate = WindowInsetsDelegate(this) protected val insetsDelegate = WindowInsetsDelegate(this)
protected val actionModeDelegate: ActionModeDelegate protected val actionModeDelegate: ActionModeDelegate

View File

@@ -9,12 +9,13 @@ import androidx.core.view.updatePadding
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.SettingsHeadersFragment import org.koitharu.kotatsu.settings.SettingsHeadersFragment
import javax.inject.Inject
@Suppress("LeakingThis")
@AndroidEntryPoint @AndroidEntryPoint
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(), PreferenceFragmentCompat(),
@@ -24,7 +25,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
@Inject @Inject
lateinit var settings: AppSettings lateinit var settings: AppSettings
@Suppress("LeakingThis") @JvmField
protected val insetsDelegate = WindowInsetsDelegate(this) protected val insetsDelegate = WindowInsetsDelegate(this)
override val recyclerView: RecyclerView override val recyclerView: RecyclerView
@@ -55,7 +56,6 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
) )
} }
@Suppress("UsePropertyAccessSyntax")
protected fun setTitle(title: CharSequence) { protected fun setTitle(title: CharSequence) {
(parentFragment as? SettingsHeadersFragment)?.setTitle(title) (parentFragment as? SettingsHeadersFragment)?.setTitle(title)
?: activity?.setTitle(title) ?: activity?.setTitle(title)

View File

@@ -3,16 +3,24 @@ package org.koitharu.kotatsu.base.ui
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CancellationException
import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.* import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
abstract class BaseViewModel : ViewModel() { abstract class BaseViewModel : ViewModel() {
@JvmField
protected val loadingCounter = CountedBooleanLiveData() protected val loadingCounter = CountedBooleanLiveData()
@JvmField
protected val errorEvent = SingleLiveEvent<Throwable>() protected val errorEvent = SingleLiveEvent<Throwable>()
val onError: LiveData<Throwable> val onError: LiveData<Throwable>
@@ -46,4 +54,4 @@ abstract class BaseViewModel : ViewModel() {
errorEvent.postCall(throwable) errorEvent.postCall(throwable)
} }
} }
} }

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.base.ui package org.koitharu.kotatsu.base.ui
import android.app.Service
import android.content.Intent import android.content.Intent
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -20,7 +19,7 @@ abstract class CoroutineIntentService : BaseService() {
final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
launchCoroutine(intent, startId) launchCoroutine(intent, startId)
return Service.START_REDELIVER_INTENT return START_REDELIVER_INTENT
} }
private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch(errorHandler(startId)) { private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch(errorHandler(startId)) {

View File

@@ -6,8 +6,6 @@ import androidx.recyclerview.widget.RecyclerView
abstract class BoundsScrollListener(private val offsetTop: Int, private val offsetBottom: Int) : abstract class BoundsScrollListener(private val offsetTop: Int, private val offsetBottom: Int) :
RecyclerView.OnScrollListener() { RecyclerView.OnScrollListener() {
constructor(offset: Int = 0) : this(offset, offset)
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy) super.onScrolled(recyclerView, dx, dy)
val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return
@@ -28,4 +26,4 @@ abstract class BoundsScrollListener(private val offsetTop: Int, private val offs
abstract fun onScrolledToStart(recyclerView: RecyclerView) abstract fun onScrolledToStart(recyclerView: RecyclerView)
abstract fun onScrolledToEnd(recyclerView: RecyclerView) abstract fun onScrolledToEnd(recyclerView: RecyclerView)
} }

View File

@@ -1,10 +1,11 @@
package org.koitharu.kotatsu.base.ui.util package org.koitharu.kotatsu.base.ui.util
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
class ActionModeDelegate { class ActionModeDelegate : OnBackPressedCallback(false) {
private var activeActionMode: ActionMode? = null private var activeActionMode: ActionMode? = null
private var listeners: MutableList<ActionModeListener>? = null private var listeners: MutableList<ActionModeListener>? = null
@@ -12,13 +13,19 @@ class ActionModeDelegate {
val isActionModeStarted: Boolean val isActionModeStarted: Boolean
get() = activeActionMode != null get() = activeActionMode != null
override fun handleOnBackPressed() {
activeActionMode?.finish()
}
fun onSupportActionModeStarted(mode: ActionMode) { fun onSupportActionModeStarted(mode: ActionMode) {
activeActionMode = mode activeActionMode = mode
isEnabled = true
listeners?.forEach { it.onActionModeStarted(mode) } listeners?.forEach { it.onActionModeStarted(mode) }
} }
fun onSupportActionModeFinished(mode: ActionMode) { fun onSupportActionModeFinished(mode: ActionMode) {
activeActionMode = null activeActionMode = null
isEnabled = false
listeners?.forEach { it.onActionModeFinished(mode) } listeners?.forEach { it.onActionModeFinished(mode) }
} }
@@ -47,4 +54,4 @@ class ActionModeDelegate {
removeListener(listener) removeListener(listener)
} }
} }
} }

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.base.ui.util
import android.app.Activity import android.app.Activity
import android.os.Bundle import android.os.Bundle
import androidx.core.app.ActivityCompat
import org.koitharu.kotatsu.base.ui.DefaultActivityLifecycleCallbacks import org.koitharu.kotatsu.base.ui.DefaultActivityLifecycleCallbacks
import java.util.WeakHashMap import java.util.WeakHashMap
import javax.inject.Inject import javax.inject.Inject
@@ -22,6 +23,6 @@ class ActivityRecreationHandle @Inject constructor() : DefaultActivityLifecycleC
fun recreateAll() { fun recreateAll() {
val snapshot = activities.keys.toList() val snapshot = activities.keys.toList()
snapshot.forEach { it.recreate() } snapshot.forEach { ActivityCompat.recreate(it) }
} }
} }

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.base.ui.util
import dagger.hilt.EntryPoint import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@EntryPoint @EntryPoint
@@ -11,8 +10,3 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
interface BaseActivityEntryPoint { interface BaseActivityEntryPoint {
val settings: AppSettings val settings: AppSettings
} }
// Hilt cannot inject into parametrized classes
fun BaseActivityEntryPoint.inject(activity: BaseActivity<*>) {
activity.settings = settings
}

View File

@@ -10,8 +10,10 @@ class WindowInsetsDelegate(
private val listener: WindowInsetsListener, private val listener: WindowInsetsListener,
) : OnApplyWindowInsetsListener, View.OnLayoutChangeListener { ) : OnApplyWindowInsetsListener, View.OnLayoutChangeListener {
@JvmField
var handleImeInsets: Boolean = false var handleImeInsets: Boolean = false
@JvmField
var interceptingWindowInsetsListener: OnApplyWindowInsetsListener? = null var interceptingWindowInsetsListener: OnApplyWindowInsetsListener? = null
private var lastInsets: Insets? = null private var lastInsets: Insets? = null
@@ -63,4 +65,4 @@ class WindowInsetsDelegate(
fun onWindowInsetsChanged(insets: Insets) fun onWindowInsetsChanged(insets: Insets)
} }
} }

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.bookmarks.domain
import android.database.SQLException import android.database.SQLException
import androidx.room.withTransaction import androidx.room.withTransaction
import javax.inject.Inject import dagger.Reusable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.base.domain.ReversibleHandle import org.koitharu.kotatsu.base.domain.ReversibleHandle
@@ -17,9 +17,9 @@ import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.mapItems import org.koitharu.kotatsu.utils.ext.mapItems
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import javax.inject.Singleton import javax.inject.Inject
@Singleton @Reusable
class BookmarksRepository @Inject constructor( class BookmarksRepository @Inject constructor(
private val db: MangaDatabase, private val db: MangaDatabase,
) { ) {

View File

@@ -48,7 +48,7 @@ class BookmarksViewModel @Inject constructor(
fun removeBookmarks(ids: Map<Manga, Set<Long>>) { fun removeBookmarks(ids: Map<Manga, Set<Long>>) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val handle = repository.removeBookmarks(ids) val handle = repository.removeBookmarks(ids)
onActionDone.postCall(ReversibleAction(R.string.bookmarks_removed, handle)) onActionDone.emitCall(ReversibleAction(R.string.bookmarks_removed, handle))
} }
} }
} }

View File

@@ -20,6 +20,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ElementsIntoSet import dagger.multibindings.ElementsIntoSet
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.CookieJar import okhttp3.CookieJar
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
@@ -40,6 +43,8 @@ import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher import org.koitharu.kotatsu.local.data.CbzFetcher
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
@@ -169,7 +174,6 @@ interface AppModule {
} }
@Provides @Provides
@Singleton
@ElementsIntoSet @ElementsIntoSet
fun provideDatabaseObservers( fun provideDatabaseObservers(
widgetUpdater: WidgetUpdater, widgetUpdater: WidgetUpdater,
@@ -184,7 +188,6 @@ interface AppModule {
) )
@Provides @Provides
@Singleton
@ElementsIntoSet @ElementsIntoSet
fun provideActivityLifecycleCallbacks( fun provideActivityLifecycleCallbacks(
appProtectHelper: AppProtectHelper, appProtectHelper: AppProtectHelper,
@@ -207,5 +210,16 @@ interface AppModule {
MemoryContentCache(application) MemoryContentCache(application)
} }
} }
@Provides
@Singleton
@LocalStorageChanges
fun provideMutableLocalStorageChangesFlow(): MutableSharedFlow<LocalManga?> = MutableSharedFlow()
@Provides
@LocalStorageChanges
fun provideLocalStorageChangesFlow(
@LocalStorageChanges flow: MutableSharedFlow<LocalManga?>,
): SharedFlow<LocalManga?> = flow.asSharedFlow()
} }
} }

View File

@@ -5,5 +5,5 @@ import okio.IOException
class CloudFlareProtectedException( class CloudFlareProtectedException(
val url: String, val url: String,
val headers: Headers, @Transient val headers: Headers,
) : IOException("Protected by CloudFlare") ) : IOException("Protected by CloudFlare")

View File

@@ -1,3 +1,3 @@
package org.koitharu.kotatsu.core.exceptions package org.koitharu.kotatsu.core.exceptions
class WrongPasswordException : SecurityException() class WrongPasswordException : IllegalArgumentException()

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.core.model
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
@@ -8,6 +9,18 @@ import org.koitharu.kotatsu.utils.ext.iterator
fun Collection<Manga>.ids() = mapToSet { it.id } fun Collection<Manga>.ids() = mapToSet { it.id }
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) {
return size
}
val acc = HashMap<String?, Int>()
for (item in this) {
val branch = item.chapter.branch
acc[branch] = (acc[branch] ?: 0) + 1
}
return acc.values.max()
}
fun Manga.getPreferredBranch(history: MangaHistory?): String? { fun Manga.getPreferredBranch(history: MangaHistory?): String? {
val ch = chapters val ch = chapters
if (ch.isNullOrEmpty()) { if (ch.isNullOrEmpty()) {
@@ -31,4 +44,4 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
} }
} }
return groups.maxByOrNull { it.value.size }?.key return groups.maxByOrNull { it.value.size }?.key
} }

View File

@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.toList import org.koitharu.kotatsu.utils.ext.toList
import java.lang.ref.WeakReference
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -28,10 +29,14 @@ class MangaLoaderContextImpl @Inject constructor(
@ApplicationContext private val androidContext: Context, @ApplicationContext private val androidContext: Context,
) : MangaLoaderContext() { ) : MangaLoaderContext() {
private var webViewCached: WeakReference<WebView>? = null
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main) { override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main) {
val webView = WebView(androidContext) val webView = webViewCached?.get() ?: WebView(androidContext).also {
webView.settings.javaScriptEnabled = true it.settings.javaScriptEnabled = true
webViewCached = WeakReference(it)
}
suspendCoroutine { cont -> suspendCoroutine { cont ->
webView.evaluateJavascript(script) { result -> webView.evaluateJavascript(script) { result ->
cont.resume(result?.takeUnless { it == "null" }) cont.resume(result?.takeUnless { it == "null" })

View File

@@ -16,7 +16,12 @@ class MangaTagHighlighter @Inject constructor(
private val dict by lazy { private val dict by lazy {
context.resources.openRawResource(R.raw.tags_redlist).use { context.resources.openRawResource(R.raw.tags_redlist).use {
val set = HashSet<String>() val set = HashSet<String>()
it.bufferedReader().forEachLine { x -> set.add(x) } it.bufferedReader().forEachLine { x ->
val line = x.trim()
if (line.isNotEmpty()) {
set.add(line)
}
}
set set
} }
} }

View File

@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
@@ -95,7 +96,12 @@ class MangaPrefetchService : CoroutineIntentService() {
val intent = Intent(context, MangaPrefetchService::class.java) val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_PAGES intent.action = ACTION_PREFETCH_PAGES
intent.putExtra(EXTRA_CHAPTER, ParcelableMangaChapters(listOf(chapter))) intent.putExtra(EXTRA_CHAPTER, ParcelableMangaChapters(listOf(chapter)))
context.startService(intent) try {
context.startService(intent)
} catch (e: IllegalStateException) {
// probably app is in background
e.printStackTraceDebug()
}
} }
fun prefetchLast(context: Context) { fun prefetchLast(context: Context) {

View File

@@ -1,9 +1,7 @@
package org.koitharu.kotatsu.details.ui package org.koitharu.kotatsu.details.ui
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle import android.os.Bundle
import android.transition.Slide import android.transition.Slide
import android.transition.TransitionManager import android.transition.TransitionManager
@@ -66,13 +64,6 @@ class DetailsActivity :
private val viewModel: DetailsViewModel by viewModels() private val viewModel: DetailsViewModel by viewModels()
private lateinit var chaptersMenuProvider: ChaptersMenuProvider private lateinit var chaptersMenuProvider: ChaptersMenuProvider
private val downloadReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val downloadedManga = DownloadService.getDownloadedManga(intent) ?: return
viewModel.onDownloadComplete(downloadedManga)
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityDetailsBinding.inflate(layoutInflater)) setContentView(ActivityDetailsBinding.inflate(layoutInflater))
@@ -130,7 +121,6 @@ class DetailsActivity :
} }
viewModel.chapters.observe(this, PrefetchObserver(this)) viewModel.chapters.observe(this, PrefetchObserver(this))
registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE))
addMenuProvider( addMenuProvider(
DetailsMenuProvider( DetailsMenuProvider(
activity = this, activity = this,
@@ -142,11 +132,6 @@ class DetailsActivity :
binding.headerChapters?.addOnExpansionChangeListener(this) ?: addMenuProvider(chaptersMenuProvider) binding.headerChapters?.addOnExpansionChangeListener(this) ?: addMenuProvider(chaptersMenuProvider)
} }
override fun onDestroy() {
unregisterReceiver(downloadReceiver)
super.onDestroy()
}
override fun onClick(v: View) { override fun onClick(v: View) {
val manga = viewModel.manga.value ?: return val manga = viewModel.manga.value ?: return
when (v.id) { when (v.id) {

View File

@@ -9,7 +9,6 @@ import android.widget.Toast
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.net.toUri
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
@@ -19,7 +18,6 @@ import coil.request.ImageRequest
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
@@ -27,6 +25,7 @@ import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.core.model.countChaptersByBranch
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
@@ -45,7 +44,6 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.computeSize
import org.koitharu.kotatsu.utils.ext.crossfade import org.koitharu.kotatsu.utils.ext.crossfade
import org.koitharu.kotatsu.utils.ext.drawableTop import org.koitharu.kotatsu.utils.ext.drawableTop
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
@@ -54,8 +52,6 @@ import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.resolveDp import org.koitharu.kotatsu.utils.ext.resolveDp
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.utils.ext.textAndVisible import org.koitharu.kotatsu.utils.ext.textAndVisible
import org.koitharu.kotatsu.utils.ext.toFileOrNull
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import org.koitharu.kotatsu.utils.image.CoverSizeResolver import org.koitharu.kotatsu.utils.image.CoverSizeResolver
import javax.inject.Inject import javax.inject.Inject
@@ -93,6 +89,7 @@ class DetailsFragment :
viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged) viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged) viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged) viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged)
viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged)
} }
override fun onItemClick(item: Bookmark, view: View) { override fun onItemClick(item: Bookmark, view: View) {
@@ -149,20 +146,9 @@ class DetailsFragment :
} }
if (manga.source == MangaSource.LOCAL) { if (manga.source == MangaSource.LOCAL) {
infoLayout.textViewSource.isVisible = false infoLayout.textViewSource.isVisible = false
val file = manga.url.toUri().toFileOrNull()
if (file != null) {
viewLifecycleScope.launch {
val size = file.computeSize()
infoLayout.textViewSize.text = FileSize.BYTES.format(requireContext(), size)
infoLayout.textViewSize.isVisible = true
}
} else {
infoLayout.textViewSize.isVisible = false
}
} else { } else {
infoLayout.textViewSource.text = manga.source.title infoLayout.textViewSource.text = manga.source.title
infoLayout.textViewSource.isVisible = true infoLayout.textViewSource.isVisible = true
infoLayout.textViewSize.isVisible = false
} }
infoLayout.textViewNsfw.isVisible = manga.isNsfw infoLayout.textViewNsfw.isVisible = manga.isNsfw
@@ -177,12 +163,9 @@ class DetailsFragment :
if (chapters.isNullOrEmpty()) { if (chapters.isNullOrEmpty()) {
infoLayout.textViewChapters.isVisible = false infoLayout.textViewChapters.isVisible = false
} else { } else {
val count = chapters.countChaptersByBranch()
infoLayout.textViewChapters.isVisible = true infoLayout.textViewChapters.isVisible = true
infoLayout.textViewChapters.text = resources.getQuantityString( infoLayout.textViewChapters.text = resources.getQuantityString(R.plurals.chapters, count, count)
R.plurals.chapters,
chapters.size,
chapters.size,
)
} }
} }
@@ -194,6 +177,16 @@ class DetailsFragment :
} }
} }
private fun onLocalSizeChanged(size: Long) {
val textView = binding.infoLayout.textViewSize
if (size == 0L) {
textView.isVisible = false
} else {
textView.text = FileSize.BYTES.format(textView.context, size)
textView.isVisible = true
}
}
private fun onHistoryChanged(history: HistoryInfo) { private fun onHistoryChanged(history: HistoryInfo) {
binding.progressView.setPercent(history.history?.percent ?: PROGRESS_NONE, animate = true) binding.progressView.setPercent(history.history?.percent ?: PROGRESS_NONE, animate = true)
} }

View File

@@ -4,6 +4,7 @@ import android.text.Html
import android.text.SpannableString import android.text.SpannableString
import android.text.Spanned import android.text.Spanned
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import androidx.core.net.toUri
import androidx.core.text.getSpans import androidx.core.text.getSpans
import androidx.core.text.parseAsHtml import androidx.core.text.parseAsHtml
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
@@ -14,6 +15,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChangedBy
@@ -36,6 +38,8 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -46,8 +50,10 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.computeSize
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.toFileOrNull
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
@@ -62,6 +68,7 @@ class DetailsViewModel @Inject constructor(
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>, private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
private val imageGetter: Html.ImageGetter, private val imageGetter: Html.ImageGetter,
private val delegate: MangaDetailsDelegate, private val delegate: MangaDetailsDelegate,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
) : BaseViewModel() { ) : BaseViewModel() {
private var loadingJob: Job private var loadingJob: Job
@@ -95,19 +102,37 @@ class DetailsViewModel @Inject constructor(
val historyInfo: LiveData<HistoryInfo> = combine( val historyInfo: LiveData<HistoryInfo> = combine(
delegate.manga, delegate.manga,
delegate.selectedBranch,
history, history,
historyRepository.observeShouldSkip(delegate.manga), historyRepository.observeShouldSkip(delegate.manga),
) { m, h, im -> ) { m, b, h, im ->
HistoryInfo(m, h, im) HistoryInfo(m, b, h, im)
}.asFlowLiveData( }.asFlowLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default, context = viewModelScope.coroutineContext + Dispatchers.Default,
defaultValue = HistoryInfo(null, null, false), defaultValue = HistoryInfo(null, null, null, false),
) )
val bookmarks = delegate.manga.flatMapLatest { val bookmarks = delegate.manga.flatMapLatest {
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList()) if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val localSize = combine(
delegate.manga,
delegate.relatedManga,
) { m1, m2 ->
val url = when {
m1?.source == MangaSource.LOCAL -> m1.url
m2?.source == MangaSource.LOCAL -> m2.url
else -> null
}
if (url != null) {
val file = url.toUri().toFileOrNull()
file?.computeSize() ?: 0L
} else {
0L
}
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, 0)
val description = delegate.manga val description = delegate.manga
.distinctUntilChangedBy { it?.description.orEmpty() } .distinctUntilChangedBy { it?.description.orEmpty() }
.transformLatest { .transformLatest {
@@ -173,6 +198,10 @@ class DetailsViewModel @Inject constructor(
init { init {
loadingJob = doLoad() loadingJob = doLoad()
launchJob(Dispatchers.Default) {
localStorageChanges
.collect { onDownloadComplete(it) }
}
} }
fun reload() { fun reload() {
@@ -194,7 +223,7 @@ class DetailsViewModel @Inject constructor(
runCatchingCancellable { runCatchingCancellable {
historyRepository.deleteOrSwap(manga, original) historyRepository.deleteOrSwap(manga, original)
} }
onMangaRemoved.postCall(manga) onMangaRemoved.emitCall(manga)
} }
} }
@@ -221,26 +250,6 @@ class DetailsViewModel @Inject constructor(
chaptersQuery.value = query?.trim().orEmpty() chaptersQuery.value = query?.trim().orEmpty()
} }
fun onDownloadComplete(downloadedManga: Manga) {
val currentManga = delegate.manga.value ?: return
if (currentManga.id != downloadedManga.id) {
return
}
if (currentManga.source == MangaSource.LOCAL) {
reload()
} else {
viewModelScope.launch(Dispatchers.Default) {
runCatchingCancellable {
localMangaRepository.getDetails(downloadedManga)
}.onSuccess {
delegate.relatedManga.value = it
}.onFailure {
it.printStackTraceDebug()
}
}
}
}
fun updateScrobbling(index: Int, rating: Float, status: ScrobblingStatus?) { fun updateScrobbling(index: Int, rating: Float, status: ScrobblingStatus?) {
val scrobbler = getScrobbler(index) ?: return val scrobbler = getScrobbler(index) ?: return
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
@@ -286,6 +295,27 @@ class DetailsViewModel @Inject constructor(
} }
} }
private suspend fun onDownloadComplete(downloadedManga: LocalManga?) {
downloadedManga ?: return
val currentManga = delegate.manga.value ?: return
if (currentManga.id != downloadedManga.manga.id) {
return
}
if (currentManga.source == MangaSource.LOCAL) {
reload()
} else {
viewModelScope.launch(Dispatchers.Default) {
runCatchingCancellable {
localMangaRepository.getDetails(downloadedManga.manga)
}.onSuccess {
delegate.relatedManga.value = it
}.onFailure {
it.printStackTraceDebug()
}
}
}
}
private fun Spanned.filterSpans(): CharSequence { private fun Spanned.filterSpans(): CharSequence {
val spannable = SpannableString.valueOf(this) val spannable = SpannableString.valueOf(this)
val spans = spannable.getSpans<ForegroundColorSpan>() val spans = spannable.getSpans<ForegroundColorSpan>()

View File

@@ -36,8 +36,13 @@ class HistoryInfo(
} }
} }
fun HistoryInfo(manga: Manga?, history: MangaHistory?, isIncognitoMode: Boolean): HistoryInfo { fun HistoryInfo(
val chapters = manga?.chapters manga: Manga?,
branch: String?,
history: MangaHistory?,
isIncognitoMode: Boolean
): HistoryInfo {
val chapters = manga?.getChapters(branch)
return HistoryInfo( return HistoryInfo(
totalChapters = chapters?.size ?: -1, totalChapters = chapters?.size ?: -1,
currentChapter = if (history != null && !chapters.isNullOrEmpty()) { currentChapter = if (history != null && !chapters.isNullOrEmpty()) {

View File

@@ -15,6 +15,7 @@ import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
@@ -29,6 +30,8 @@ import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.ui.service.PausingHandle import org.koitharu.kotatsu.download.ui.service.PausingHandle
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
@@ -38,6 +41,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.copyToSuspending import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.ifNullOrEmpty
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.progress.PausingProgressJob import org.koitharu.kotatsu.utils.progress.PausingProgressJob
@@ -58,6 +62,7 @@ class DownloadManager @Inject constructor(
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
) { ) {
private val coverWidth = context.resources.getDimensionPixelSize( private val coverWidth = context.resources.getDimensionPixelSize(
@@ -120,9 +125,11 @@ class DownloadManager @Inject constructor(
outState.value = DownloadState.Preparing(startId, manga, cover) outState.value = DownloadState.Preparing(startId, manga, cover)
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = LocalMangaOutput.getOrCreate(destination, data) output = LocalMangaOutput.getOrCreate(destination, data)
val coverUrl = data.largeCoverUrl ?: data.coverUrl val coverUrl = data.largeCoverUrl.ifNullOrEmpty { data.coverUrl }
downloadFile(coverUrl, destination, tempFileName, repo.source).let { file -> if (coverUrl.isNotEmpty()) {
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) downloadFile(coverUrl, destination, tempFileName, repo.source).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
}
} }
val chapters = checkNotNull( val chapters = checkNotNull(
if (chaptersIdsSet == null) { if (chaptersIdsSet == null) {
@@ -165,13 +172,18 @@ class DownloadManager @Inject constructor(
delay(SLOWDOWN_DELAY) delay(SLOWDOWN_DELAY)
} }
} }
output.flushChapter(chapter) if (output.flushChapter(chapter)) {
runCatchingCancellable {
localStorageChanges.emit(LocalMangaInput.of(output.rootFile).getManga())
}.onFailure(Throwable::printStackTraceDebug)
}
} }
outState.value = DownloadState.PostProcessing(startId, data, cover) outState.value = DownloadState.PostProcessing(startId, data, cover)
output.mergeWithExisting() output.mergeWithExisting()
output.finish() output.finish()
val localManga = LocalMangaInput.of(output.rootFile).getManga().manga val localManga = LocalMangaInput.of(output.rootFile).getManga()
outState.value = DownloadState.Done(startId, data, cover, localManga) localStorageChanges.emit(localManga)
outState.value = DownloadState.Done(startId, data, cover, localManga.manga)
} catch (e: CancellationException) { } catch (e: CancellationException) {
outState.value = DownloadState.Cancelled(startId, manga, cover) outState.value = DownloadState.Cancelled(startId, manga, cover)
throw e throw e

View File

@@ -10,6 +10,7 @@ import android.text.format.DateUtils
import android.util.SparseArray import android.util.SparseArray
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat
@@ -28,7 +29,6 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.ellipsize import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DownloadNotification(private val context: Context) { class DownloadNotification(private val context: Context) {
@@ -37,18 +37,20 @@ class DownloadNotification(private val context: Context) {
private val states = SparseArray<DownloadState>() private val states = SparseArray<DownloadState>()
private val groupBuilder = NotificationCompat.Builder(context, CHANNEL_ID) private val groupBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
private val queueIntent = PendingIntent.getActivity( private val queueIntent = PendingIntentCompat.getActivity(
context, context,
REQUEST_QUEUE, REQUEST_QUEUE,
DownloadsActivity.newIntent(context), DownloadsActivity.newIntent(context),
PendingIntentCompat.FLAG_IMMUTABLE, 0,
false,
) )
private val localListIntent = PendingIntent.getActivity( private val localListIntent = PendingIntentCompat.getActivity(
context, context,
REQUEST_LIST_LOCAL, REQUEST_LIST_LOCAL,
MangaListActivity.newIntent(context, MangaSource.LOCAL), MangaListActivity.newIntent(context, MangaSource.LOCAL),
PendingIntentCompat.FLAG_IMMUTABLE, 0,
false,
) )
init { init {
@@ -78,31 +80,37 @@ class DownloadNotification(private val context: Context) {
progress++ progress++
context.getString(R.string.cancelling_) context.getString(R.string.cancelling_)
} }
is DownloadState.Done -> { is DownloadState.Done -> {
progress++ progress++
context.getString(R.string.download_complete) context.getString(R.string.download_complete)
} }
is DownloadState.Error -> { is DownloadState.Error -> {
isAllDone = false isAllDone = false
context.getString(R.string.error) context.getString(R.string.error)
} }
is DownloadState.PostProcessing -> { is DownloadState.PostProcessing -> {
progress++ progress++
isInProgress = true isInProgress = true
isAllDone = false isAllDone = false
context.getString(R.string.processing_) context.getString(R.string.processing_)
} }
is DownloadState.Preparing -> { is DownloadState.Preparing -> {
isAllDone = false isAllDone = false
isInProgress = true isInProgress = true
context.getString(R.string.preparing_) context.getString(R.string.preparing_)
} }
is DownloadState.Progress -> { is DownloadState.Progress -> {
isAllDone = false isAllDone = false
isInProgress = true isInProgress = true
progress += state.percent progress += state.percent
context.getString(R.string.percent_string_pattern, (state.percent * 100).format()) context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
} }
is DownloadState.Queued -> { is DownloadState.Queued -> {
isAllDone = false isAllDone = false
isInProgress = true isInProgress = true
@@ -158,21 +166,23 @@ class DownloadNotification(private val context: Context) {
private val cancelAction = NotificationCompat.Action( private val cancelAction = NotificationCompat.Action(
materialR.drawable.material_ic_clear_black_24dp, materialR.drawable.material_ic_clear_black_24dp,
context.getString(android.R.string.cancel), context.getString(android.R.string.cancel),
PendingIntent.getBroadcast( PendingIntentCompat.getBroadcast(
context, context,
startId * 2, startId * 2,
DownloadService.getCancelIntent(startId), DownloadService.getCancelIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE, PendingIntent.FLAG_CANCEL_CURRENT,
false,
), ),
) )
private val retryAction = NotificationCompat.Action( private val retryAction = NotificationCompat.Action(
R.drawable.ic_restart_black, R.drawable.ic_restart_black,
context.getString(R.string.try_again), context.getString(R.string.try_again),
PendingIntent.getBroadcast( PendingIntentCompat.getBroadcast(
context, context,
startId * 2 + 1, startId * 2 + 1,
DownloadService.getResumeIntent(startId), DownloadService.getResumeIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE, PendingIntent.FLAG_CANCEL_CURRENT,
false,
), ),
) )
@@ -213,6 +223,7 @@ class DownloadNotification(private val context: Context) {
builder.setOngoing(true) builder.setOngoing(true)
builder.priority = NotificationCompat.PRIORITY_DEFAULT builder.priority = NotificationCompat.PRIORITY_DEFAULT
} }
is DownloadState.Done -> { is DownloadState.Done -> {
builder.setProgress(0, 0, false) builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.download_complete)) builder.setContentText(context.getString(R.string.download_complete))
@@ -226,6 +237,7 @@ class DownloadNotification(private val context: Context) {
builder.setWhen(System.currentTimeMillis()) builder.setWhen(System.currentTimeMillis())
builder.priority = NotificationCompat.PRIORITY_DEFAULT builder.priority = NotificationCompat.PRIORITY_DEFAULT
} }
is DownloadState.Error -> { is DownloadState.Error -> {
val message = state.error.getDisplayMessage(context.resources) val message = state.error.getDisplayMessage(context.resources)
builder.setProgress(0, 0, false) builder.setProgress(0, 0, false)
@@ -244,6 +256,7 @@ class DownloadNotification(private val context: Context) {
} }
builder.priority = NotificationCompat.PRIORITY_DEFAULT builder.priority = NotificationCompat.PRIORITY_DEFAULT
} }
is DownloadState.PostProcessing -> { is DownloadState.PostProcessing -> {
builder.setProgress(1, 0, true) builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.processing_)) builder.setContentText(context.getString(R.string.processing_))
@@ -251,6 +264,7 @@ class DownloadNotification(private val context: Context) {
builder.setOngoing(true) builder.setOngoing(true)
builder.priority = NotificationCompat.PRIORITY_DEFAULT builder.priority = NotificationCompat.PRIORITY_DEFAULT
} }
is DownloadState.Queued -> { is DownloadState.Queued -> {
builder.setProgress(0, 0, false) builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.queued)) builder.setContentText(context.getString(R.string.queued))
@@ -259,6 +273,7 @@ class DownloadNotification(private val context: Context) {
builder.addAction(cancelAction) builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_LOW builder.priority = NotificationCompat.PRIORITY_LOW
} }
is DownloadState.Preparing -> { is DownloadState.Preparing -> {
builder.setProgress(1, 0, true) builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.preparing_)) builder.setContentText(context.getString(R.string.preparing_))
@@ -267,6 +282,7 @@ class DownloadNotification(private val context: Context) {
builder.addAction(cancelAction) builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_DEFAULT builder.priority = NotificationCompat.PRIORITY_DEFAULT
} }
is DownloadState.Progress -> { is DownloadState.Progress -> {
builder.setProgress(state.max, state.progress, false) builder.setProgress(state.max, state.progress, false)
val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format()) val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
@@ -302,11 +318,12 @@ class DownloadNotification(private val context: Context) {
manager.notify(ID_GROUP, notification) manager.notify(ID_GROUP, notification)
} }
private fun createMangaIntent(context: Context, manga: Manga) = PendingIntent.getActivity( private fun createMangaIntent(context: Context, manga: Manga) = PendingIntentCompat.getActivity(
context, context,
manga.hashCode(), manga.hashCode(),
DetailsActivity.newIntent(context, manga), DetailsActivity.newIntent(context, manga),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE, PendingIntent.FLAG_CANCEL_CURRENT,
false,
) )
companion object { companion object {

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.download.ui.service package org.koitharu.kotatsu.download.ui.service
import android.app.DownloadManager.ACTION_DOWNLOAD_COMPLETE
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -56,7 +57,6 @@ class DownloadService : BaseService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
isRunning = true
downloadNotification = DownloadNotification(this) downloadNotification = DownloadNotification(this)
wakeLock = (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager) wakeLock = (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
@@ -93,7 +93,6 @@ class DownloadService : BaseService() {
if (wakeLock.isHeld) { if (wakeLock.isHeld) {
wakeLock.release() wakeLock.release()
} }
isRunning = false
super.onDestroy() super.onDestroy()
} }
@@ -205,12 +204,6 @@ class DownloadService : BaseService() {
companion object { companion object {
var isRunning: Boolean = false
private set
@Deprecated("Use LocalMangaRepository.watchReadableDirs instead")
const val ACTION_DOWNLOAD_COMPLETE = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL" private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
private const val ACTION_DOWNLOAD_RESUME = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_RESUME" private const val ACTION_DOWNLOAD_RESUME = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_RESUME"
@@ -259,13 +252,6 @@ class DownloadService : BaseService() {
fun getResumeIntent(startId: Int) = Intent(ACTION_DOWNLOAD_RESUME) fun getResumeIntent(startId: Int) = Intent(ACTION_DOWNLOAD_RESUME)
.putExtra(EXTRA_CANCEL_ID, startId) .putExtra(EXTRA_CANCEL_ID, startId)
fun getDownloadedManga(intent: Intent?): Manga? {
if (intent?.action == ACTION_DOWNLOAD_COMPLETE) {
return intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga
}
return null
}
private fun showStartedSnackbar(view: View) { private fun showStartedSnackbar(view: View) {
Snackbar.make(view, R.string.download_started, Snackbar.LENGTH_LONG) Snackbar.make(view, R.string.download_started, Snackbar.LENGTH_LONG)
.setAction(R.string.details) { .setAction(R.string.details) {

View File

@@ -54,7 +54,7 @@ class ExploreViewModel @Inject constructor(
fun openRandom() { fun openRandom() {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
val manga = exploreRepository.findRandomManga(tagsLimit = 8) val manga = exploreRepository.findRandomManga(tagsLimit = 8)
onOpenManga.postCall(manga) onOpenManga.emitCall(manga)
} }
} }
@@ -64,7 +64,7 @@ class ExploreViewModel @Inject constructor(
val rollback = ReversibleHandle { val rollback = ReversibleHandle {
settings.hiddenSources -= source.name settings.hiddenSources -= source.name
} }
onActionDone.postCall(ReversibleAction(R.string.source_disabled, rollback)) onActionDone.emitCall(ReversibleAction(R.string.source_disabled, rollback))
} }
} }

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.favourites.domain package org.koitharu.kotatsu.favourites.domain
import androidx.room.withTransaction import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
@@ -22,9 +23,8 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
import org.koitharu.kotatsu.utils.ext.mapItems import org.koitharu.kotatsu.utils.ext.mapItems
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@Singleton @Reusable
class FavouritesRepository @Inject constructor( class FavouritesRepository @Inject constructor(
private val db: MangaDatabase, private val db: MangaDatabase,
private val channels: TrackerNotificationChannels, private val channels: TrackerNotificationChannels,

View File

@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEdit
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.NO_ID import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.NO_ID
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.emitValue
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -34,7 +35,7 @@ class FavouritesCategoryEditViewModel @Inject constructor(
init { init {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
category.postValue( category.emitValue(
if (categoryId != NO_ID) { if (categoryId != NO_ID) {
repository.getCategory(categoryId) repository.getCategory(categoryId)
} else { } else {
@@ -57,7 +58,7 @@ class FavouritesCategoryEditViewModel @Inject constructor(
} else { } else {
repository.updateCategory(categoryId, title, sortOrder, isTrackerEnabled, isVisibleOnShelf) repository.updateCategory(categoryId, title, sortOrder, isTrackerEnabled, isVisibleOnShelf)
} }
onSaved.postCall(Unit) onSaved.emitCall(Unit)
} }
} }
} }

View File

@@ -9,7 +9,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.util.ReversibleAction import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
@@ -28,7 +27,6 @@ import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -43,9 +41,6 @@ class FavouritesListViewModel @Inject constructor(
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
var categoryName: String? = null
private set
val sortOrder: LiveData<SortOrder?> = if (categoryId == NO_ID) { val sortOrder: LiveData<SortOrder?> = if (categoryId == NO_ID) {
MutableLiveData(null) MutableLiveData(null)
} else { } else {
@@ -82,18 +77,6 @@ class FavouritesListViewModel @Inject constructor(
emit(listOf(it.toErrorState(canRetry = false))) emit(listOf(it.toErrorState(canRetry = false)))
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
init {
if (categoryId != NO_ID) {
launchJob {
categoryName = withContext(Dispatchers.Default) {
runCatchingCancellable {
repository.getCategory(categoryId).title
}.getOrNull()
}
}
}
}
override fun onRefresh() = Unit override fun onRefresh() = Unit
override fun onRetry() = Unit override fun onRetry() = Unit
@@ -108,7 +91,7 @@ class FavouritesListViewModel @Inject constructor(
} else { } else {
repository.removeFromCategory(categoryId, ids) repository.removeFromCategory(categoryId, ids)
} }
onActionDone.postCall(ReversibleAction(R.string.removed_from_favourites, handle)) onActionDone.emitCall(ReversibleAction(R.string.removed_from_favourites, handle))
} }
} }

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.history.domain package org.koitharu.kotatsu.history.domain
import androidx.room.withTransaction import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChangedBy
@@ -27,11 +28,10 @@ import org.koitharu.kotatsu.scrobbling.common.domain.tryScrobble
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.mapItems import org.koitharu.kotatsu.utils.ext.mapItems
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
const val PROGRESS_NONE = -1f const val PROGRESS_NONE = -1f
@Singleton @Reusable
class HistoryRepository @Inject constructor( class HistoryRepository @Inject constructor(
private val db: MangaDatabase, private val db: MangaDatabase,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,

View File

@@ -29,6 +29,7 @@ import org.koitharu.kotatsu.list.ui.model.toListModel
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.daysDiff import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.emitValue
import org.koitharu.kotatsu.utils.ext.onFirst import org.koitharu.kotatsu.utils.ext.onFirst
import java.util.Date import java.util.Date
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -45,7 +46,7 @@ class HistoryListViewModel @Inject constructor(
val isGroupingEnabled = MutableLiveData<Boolean>() val isGroupingEnabled = MutableLiveData<Boolean>()
private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { isHistoryGroupingEnabled } private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { isHistoryGroupingEnabled }
.onEach { isGroupingEnabled.postValue(it) } .onEach { isGroupingEnabled.emitValue(it) }
override val content = combine( override val content = combine(
repository.observeAllWithHistory(), repository.observeAllWithHistory(),
@@ -77,7 +78,7 @@ class HistoryListViewModel @Inject constructor(
override fun onRetry() = Unit override fun onRetry() = Unit
fun clearHistory() { fun clearHistory() {
launchLoadingJob { launchLoadingJob(Dispatchers.Default) {
repository.clear() repository.clear()
} }
} }
@@ -88,7 +89,7 @@ class HistoryListViewModel @Inject constructor(
} }
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val handle = repository.delete(ids) val handle = repository.delete(ids)
onActionDone.postCall(ReversibleAction(R.string.removed_from_history, handle)) onActionDone.emitCall(ReversibleAction(R.string.removed_from_history, handle))
} }
} }

View File

@@ -17,11 +17,9 @@ import androidx.core.view.updatePadding
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.FitHeightGridLayoutManager import org.koitharu.kotatsu.base.ui.list.FitHeightGridLayoutManager
import org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager import org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager
@@ -30,7 +28,7 @@ import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration
import org.koitharu.kotatsu.base.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.base.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.base.ui.util.ReversibleAction import org.koitharu.kotatsu.base.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
@@ -46,7 +44,6 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.main.ui.MainActivity import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
@@ -127,7 +124,7 @@ abstract class MangaListFragment :
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.content.observe(viewLifecycleOwner, ::onListChanged) viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone) viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -173,17 +170,6 @@ abstract class MangaListFragment :
listAdapter?.setItems(list, listCommitCallback) listAdapter?.setItems(list, listCommitCallback)
} }
private fun onActionDone(action: ReversibleAction) {
val handle = action.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
val snackbar = Snackbar.make(binding.recyclerView, action.stringResId, length)
snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav
if (handle != null) {
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
}
snackbar.show()
}
private fun resolveException(e: Throwable) { private fun resolveException(e: Throwable) {
if (ExceptionResolver.canResolve(e)) { if (ExceptionResolver.canResolve(e)) {
viewLifecycleScope.launch { viewLifecycleScope.launch {

View File

@@ -1,3 +1,5 @@
@file:androidx.annotation.OptIn(ExperimentalBadgeUtils::class)
package org.koitharu.kotatsu.list.ui.adapter package org.koitharu.kotatsu.list.ui.adapter
import android.view.View import android.view.View
@@ -5,6 +7,7 @@ import androidx.annotation.CheckResult
import androidx.core.view.doOnNextLayout import androidx.core.view.doOnNextLayout
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
import com.google.android.material.badge.BadgeUtils import com.google.android.material.badge.BadgeUtils
import com.google.android.material.badge.ExperimentalBadgeUtils
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@CheckResult @CheckResult

View File

@@ -1,12 +1,19 @@
package org.koitharu.kotatsu.list.ui.adapter package org.koitharu.kotatsu.list.ui.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding
import org.koitharu.kotatsu.list.ui.model.EmptyHint import org.koitharu.kotatsu.list.ui.model.EmptyHint
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.setTextAndVisible import org.koitharu.kotatsu.utils.ext.setTextAndVisible
fun emptyHintAD( fun emptyHintAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
listener: ListStateHolderListener, listener: ListStateHolderListener,
) = adapterDelegateViewBinding<EmptyHint, ListModel, ItemEmptyCardBinding>( ) = adapterDelegateViewBinding<EmptyHint, ListModel, ItemEmptyCardBinding>(
{ inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) }, { inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) },
@@ -15,9 +22,13 @@ fun emptyHintAD(
binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() } binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() }
bind { bind {
binding.icon.setImageResource(item.icon) binding.icon.newImageRequest(lifecycleOwner, item.icon)?.enqueueWith(coil)
binding.textPrimary.setText(item.textPrimary) binding.textPrimary.setText(item.textPrimary)
binding.textSecondary.setTextAndVisible(item.textSecondary) binding.textSecondary.setTextAndVisible(item.textSecondary)
binding.buttonRetry.setTextAndVisible(item.actionStringRes) binding.buttonRetry.setTextAndVisible(item.actionStringRes)
} }
onViewRecycled {
binding.icon.disposeImageRequest()
}
} }

View File

@@ -1 +0,0 @@
package org.koitharu.kotatsu.local

View File

@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.local.data package org.koitharu.kotatsu.local.data
import androidx.core.net.toFile
import androidx.core.net.toUri
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import java.io.File import java.io.File
@@ -9,6 +11,8 @@ class LocalManga(
val manga: Manga, val manga: Manga,
) { ) {
constructor(manga: Manga) : this(manga.url.toUri().toFile(), manga)
var createdAt: Long = -1L var createdAt: Long = -1L
private set private set
get() { get() {

View File

@@ -4,30 +4,25 @@ import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.os.StatFs import android.os.StatFs
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.Cache import okhttp3.Cache
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.util.observe
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.utils.ext.computeSize import org.koitharu.kotatsu.utils.ext.computeSize
import org.koitharu.kotatsu.utils.ext.getStorageName import org.koitharu.kotatsu.utils.ext.getStorageName
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
private const val DIR_NAME = "manga" private const val DIR_NAME = "manga"
private const val CACHE_DISK_PERCENTAGE = 0.02 private const val CACHE_DISK_PERCENTAGE = 0.02
private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB
private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
@Singleton @Reusable
class LocalStorageManager @Inject constructor( class LocalStorageManager @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val settings: AppSettings, private val settings: AppSettings,
@@ -36,6 +31,7 @@ class LocalStorageManager @Inject constructor(
val contentResolver: ContentResolver val contentResolver: ContentResolver
get() = context.contentResolver get() = context.contentResolver
@WorkerThread
fun createHttpCache(): Cache { fun createHttpCache(): Cache {
val directory = File(context.externalCacheDir ?: context.cacheDir, "http") val directory = File(context.externalCacheDir ?: context.cacheDir, "http")
directory.mkdirs() directory.mkdirs()
@@ -80,14 +76,6 @@ class LocalStorageManager @Inject constructor(
fun getStorageDisplayName(file: File) = file.getStorageName(context) fun getStorageDisplayName(file: File) = file.getStorageName(context)
fun observe(files: List<File>): Flow<File> {
if (files.isEmpty()) {
return emptyFlow()
}
return files.asFlow()
.flatMapMerge(files.size) { it.observe() }
}
@WorkerThread @WorkerThread
private fun getConfiguredStorageDirs(): MutableSet<File> { private fun getConfiguredStorageDirs(): MutableSet<File> {
val set = getAvailableStorageDirs() val set = getAvailableStorageDirs()

View File

@@ -84,7 +84,7 @@ class MangaIndex(source: String?) {
fun getCoverEntry(): String? = json.getStringOrNull("cover_entry") fun getCoverEntry(): String? = json.getStringOrNull("cover_entry")
fun addChapter(chapter: MangaChapter) { fun addChapter(chapter: MangaChapter, filename: String?) {
val chapters = json.getJSONObject("chapters") val chapters = json.getJSONObject("chapters")
if (!chapters.has(chapter.id.toString())) { if (!chapters.has(chapter.id.toString())) {
val jo = JSONObject() val jo = JSONObject()
@@ -95,6 +95,7 @@ class MangaIndex(source: String?) {
jo.put("scanlator", chapter.scanlator) jo.put("scanlator", chapter.scanlator)
jo.put("branch", chapter.branch) jo.put("branch", chapter.branch)
jo.put("entries", "%08d_%03d\\d{3}".format(chapter.branch.hashCode(), chapter.number)) jo.put("entries", "%08d_%03d\\d{3}".format(chapter.branch.hashCode(), chapter.number))
jo.put("file", filename)
chapters.put(chapter.id.toString(), jo) chapters.put(chapter.id.toString(), jo)
} }
} }
@@ -103,6 +104,10 @@ class MangaIndex(source: String?) {
return json.getJSONObject("chapters").remove(id.toString()) != null return json.getJSONObject("chapters").remove(id.toString()) != null
} }
fun getChapterFileName(chapterId: Long): String? {
return json.optJSONObject("chapters")?.optJSONObject(chapterId.toString())?.getStringOrNull("file")
}
fun setCoverEntry(name: String) { fun setCoverEntry(name: String) {
json.put("cover_entry", name) json.put("cover_entry", name)
} }

View File

@@ -6,9 +6,12 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.copyToSuspending import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.subdir import org.koitharu.kotatsu.utils.ext.subdir
import org.koitharu.kotatsu.utils.ext.takeIfReadable import org.koitharu.kotatsu.utils.ext.takeIfReadable
import org.koitharu.kotatsu.utils.ext.takeIfWriteable import org.koitharu.kotatsu.utils.ext.takeIfWriteable
@@ -20,47 +23,41 @@ import javax.inject.Singleton
@Singleton @Singleton
class PagesCache @Inject constructor(@ApplicationContext context: Context) { class PagesCache @Inject constructor(@ApplicationContext context: Context) {
private val cacheDir = checkNotNull(findSuitableDir(context)) { private val cacheDir = SuspendLazy {
val dirs = (context.externalCacheDirs + context.cacheDir).joinToString(";") { val dirs = context.externalCacheDirs + context.cacheDir
it?.absolutePath.toString() dirs.firstNotNullOf {
it?.subdir(CacheDir.PAGES.dir)?.takeIfWriteable()
} }
"Cannot find any suitable directory for PagesCache: [$dirs]"
} }
private val lruCache = createDiskLruCacheSafe( private val lruCache = SuspendLazy {
dir = cacheDir, val dir = cacheDir.get()
size = FileSize.MEGABYTES.convert(200, FileSize.BYTES), val size = FileSize.MEGABYTES.convert(200, FileSize.BYTES)
) runCatchingCancellable {
DiskLruCache.create(dir, size)
}.recoverCatching { error ->
error.printStackTraceDebug()
dir.deleteRecursively()
dir.mkdir()
DiskLruCache.create(dir, size)
}.getOrThrow()
}
suspend fun get(url: String): File? = runInterruptible(Dispatchers.IO) { suspend fun get(url: String): File? {
lruCache.get(url)?.takeIfReadable() val cache = lruCache.get()
return runInterruptible(Dispatchers.IO) {
cache.get(url)?.takeIfReadable()
}
} }
suspend fun put(url: String, inputStream: InputStream): File = withContext(Dispatchers.IO) { suspend fun put(url: String, inputStream: InputStream): File = withContext(Dispatchers.IO) {
val file = File(cacheDir.parentFile, url.longHashCode().toString()) val file = File(cacheDir.get().parentFile, url.longHashCode().toString())
try { try {
file.outputStream().use { out -> file.outputStream().use { out ->
inputStream.copyToSuspending(out) inputStream.copyToSuspending(out)
} }
lruCache.put(url, file) lruCache.get().put(url, file)
} finally { } finally {
file.delete() file.delete()
} }
} }
} }
private fun createDiskLruCacheSafe(dir: File, size: Long): DiskLruCache {
return try {
DiskLruCache.create(dir, size)
} catch (e: Exception) {
dir.deleteRecursively()
dir.mkdir()
DiskLruCache.create(dir, size)
}
}
private fun findSuitableDir(context: Context): File? {
val dirs = context.externalCacheDirs + context.cacheDir
return dirs.firstNotNullOfOrNull {
it?.subdir(CacheDir.PAGES.dir)?.takeIfWriteable()
}
}

View File

@@ -0,0 +1,7 @@
package org.koitharu.kotatsu.local.data
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class LocalStorageChanges

View File

@@ -6,11 +6,13 @@ import androidx.documentfile.provider.DocumentFile
import dagger.Reusable import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.utils.ext.copyToSuspending import org.koitharu.kotatsu.utils.ext.copyToSuspending
@@ -23,16 +25,19 @@ import javax.inject.Inject
class SingleMangaImporter @Inject constructor( class SingleMangaImporter @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val storageManager: LocalStorageManager, private val storageManager: LocalStorageManager,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
) { ) {
private val contentResolver = context.contentResolver private val contentResolver = context.contentResolver
suspend fun import(uri: Uri, progressState: MutableStateFlow<Float>?): LocalManga { suspend fun import(uri: Uri, progressState: MutableStateFlow<Float>?): LocalManga {
return if (isDirectory(uri)) { val result = if (isDirectory(uri)) {
importDirectory(uri, progressState) importDirectory(uri, progressState)
} else { } else {
importFile(uri, progressState) importFile(uri, progressState)
} }
localStorageChanges.emit(result)
return result
} }
private suspend fun importFile(uri: Uri, progressState: MutableStateFlow<Float>?): LocalManga { private suspend fun importFile(uri: Uri, progressState: MutableStateFlow<Float>?): LocalManga {

View File

@@ -54,13 +54,14 @@ class LocalMangaDirOutput(
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
output.put(name, file) output.put(name, file)
} }
index.addChapter(chapter) index.addChapter(chapter, chapterFileName(chapter))
} }
override suspend fun flushChapter(chapter: MangaChapter) { override suspend fun flushChapter(chapter: MangaChapter): Boolean {
val output = chaptersOutput.remove(chapter) ?: return val output = chaptersOutput.remove(chapter) ?: return false
output.flushAndFinish() output.flushAndFinish()
flushIndex() flushIndex()
return true
} }
override suspend fun finish() { override suspend fun finish() {
@@ -104,7 +105,18 @@ class LocalMangaDirOutput(
} }
private fun chapterFileName(chapter: MangaChapter): String { private fun chapterFileName(chapter: MangaChapter): String {
return "${chapter.number}_${chapter.name.toFileNameSafe()}".take(18) + ".cbz" index.getChapterFileName(chapter.id)?.let {
return it
}
val baseName = "${chapter.number}_${chapter.name.toFileNameSafe()}".take(18)
var i = 0
while (true) {
val name = (if (i == 0) baseName else baseName + "_$i") + ".cbz"
if (!File(rootFile, name).exists()) {
return name
}
i++
}
} }
private suspend fun flushIndex() = runInterruptible(Dispatchers.IO) { private suspend fun flushIndex() = runInterruptible(Dispatchers.IO) {

View File

@@ -16,7 +16,7 @@ sealed class LocalMangaOutput(
abstract suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) abstract suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String)
abstract suspend fun flushChapter(chapter: MangaChapter) abstract suspend fun flushChapter(chapter: MangaChapter): Boolean
abstract suspend fun finish() abstract suspend fun finish()

View File

@@ -57,10 +57,10 @@ class LocalMangaZipOutput(
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
output.put(name, file) output.put(name, file)
} }
index.addChapter(chapter) index.addChapter(chapter, null)
} }
override suspend fun flushChapter(chapter: MangaChapter) = Unit override suspend fun flushChapter(chapter: MangaChapter): Boolean = false
override suspend fun finish() { override suspend fun finish() {
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
@@ -98,7 +98,7 @@ class LocalMangaZipOutput(
} }
otherIndex?.getMangaInfo()?.chapters?.let { chapters -> otherIndex?.getMangaInfo()?.chapters?.let { chapters ->
for (chapter in chapters) { for (chapter in chapters) {
index.addChapter(chapter) index.addChapter(chapter, null)
} }
} }
} }

View File

@@ -1,48 +0,0 @@
package org.koitharu.kotatsu.local.data.util
import android.os.Build
import android.os.FileObserver
import androidx.annotation.RequiresApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.ProducerScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import java.io.File
fun File.observe() = callbackFlow {
val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
FlowFileObserverQ(this, this@observe)
} else {
FlowFileObserver(this, this@observe)
}
observer.startWatching()
awaitClose { observer.stopWatching() }
}.flowOn(Dispatchers.IO)
@RequiresApi(Build.VERSION_CODES.Q)
private class FlowFileObserverQ(
private val producerScope: ProducerScope<File>,
private val file: File,
) : FileObserver(file, CREATE or DELETE or CLOSE_WRITE) {
override fun onEvent(event: Int, path: String?) {
producerScope.trySendBlocking(
if (path == null) file else file.resolve(path),
)
}
}
@Suppress("DEPRECATION")
private class FlowFileObserver(
private val producerScope: ProducerScope<File>,
private val file: File,
) : FileObserver(file.absolutePath, CREATE or DELETE or CLOSE_WRITE) {
override fun onEvent(event: Int, path: String?) {
producerScope.trySendBlocking(
if (path == null) file else file.resolve(path),
)
}
}

View File

@@ -6,11 +6,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.data.TempFileFilter import org.koitharu.kotatsu.local.data.TempFileFilter
import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.input.LocalMangaInput
@@ -34,7 +37,10 @@ import javax.inject.Singleton
private const val MAX_PARALLELISM = 4 private const val MAX_PARALLELISM = 4
@Singleton @Singleton
class LocalMangaRepository @Inject constructor(private val storageManager: LocalStorageManager) : MangaRepository { class LocalMangaRepository @Inject constructor(
private val storageManager: LocalStorageManager,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
) : MangaRepository {
override val source = MangaSource.LOCAL override val source = MangaSource.LOCAL
private val locks = CompositeMutex<Long>() private val locks = CompositeMutex<Long>()
@@ -84,13 +90,18 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
suspend fun delete(manga: Manga): Boolean { suspend fun delete(manga: Manga): Boolean {
val file = Uri.parse(manga.url).toFile() val file = Uri.parse(manga.url).toFile()
return file.deleteAwait() val result = file.deleteAwait()
if (result) {
localStorageChanges.emit(null)
}
return result
} }
suspend fun deleteChapters(manga: Manga, ids: Set<Long>) { suspend fun deleteChapters(manga: Manga, ids: Set<Long>) {
lockManga(manga.id) lockManga(manga.id)
try { try {
LocalMangaUtil(manga).deleteChapters(ids) LocalMangaUtil(manga).deleteChapters(ids)
localStorageChanges.emit(LocalManga(manga))
} finally { } finally {
unlockManga(manga.id) unlockManga(manga.id)
} }
@@ -106,21 +117,24 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
suspend fun findSavedManga(remoteManga: Manga): LocalManga? { suspend fun findSavedManga(remoteManga: Manga): LocalManga? {
val files = getAllFiles() val files = getAllFiles()
val input = files.firstNotNullOfOrNull { file -> if (files.isEmpty()) {
LocalMangaInput.of(file).takeIf { return null
runCatchingCancellable {
it.getMangaInfo()
}.getOrNull()?.id == remoteManga.id
}
} }
return input?.getManga() return channelFlow {
} for (file in files) {
launch {
suspend fun watchReadableDirs(): Flow<File> { val mangaInput = LocalMangaInput.of(file)
val filter = TempFileFilter() runCatchingCancellable {
val dirs = storageManager.getReadableDirs() val mangaInfo = mangaInput.getMangaInfo()
return storageManager.observe(dirs) if (mangaInfo != null && mangaInfo.id == remoteManga.id) {
.filterNot { filter.accept(it, it.name) } send(mangaInput)
}
}.onFailure {
it.printStackTraceDebug()
}
}
}
}.firstOrNull()?.getManga()
} }
override val sortOrders = setOf(SortOrder.ALPHABETICAL, SortOrder.RATING) override val sortOrders = setOf(SortOrder.ALPHABETICAL, SortOrder.RATING)
@@ -149,7 +163,7 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
dirs.flatMap { dir -> dirs.flatMap { dir ->
dir.listFiles(TempFileFilter())?.toList().orEmpty() dir.listFiles(TempFileFilter())?.toList().orEmpty()
}.forEach { file -> }.forEach { file ->
file.delete() file.deleteRecursively()
} }
} }
return true return true

View File

@@ -8,6 +8,7 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.work.Constraints import androidx.work.Constraints
@@ -27,7 +28,6 @@ import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.local.data.importer.SingleMangaImporter import org.koitharu.kotatsu.local.data.importer.SingleMangaImporter
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.toBitmapOrNull import org.koitharu.kotatsu.utils.ext.toBitmapOrNull
import org.koitharu.kotatsu.utils.ext.toUriOrNull import org.koitharu.kotatsu.utils.ext.toUriOrNull
@@ -99,11 +99,12 @@ class ImportWorker @AssistedInject constructor(
notification.setSubText(manga.title) notification.setSubText(manga.title)
val intent = DetailsActivity.newIntent(applicationContext, manga) val intent = DetailsActivity.newIntent(applicationContext, manga)
notification.setContentIntent( notification.setContentIntent(
PendingIntent.getActivity( PendingIntentCompat.getActivity(
applicationContext, applicationContext,
manga.id.toInt(), manga.id.toInt(),
intent, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT,
false,
), ),
).setAutoCancel(true) ).setAutoCancel(true)
.setVisibility( .setVisibility(

View File

@@ -9,10 +9,12 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.MutableSharedFlow
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.CoroutineIntentService import org.koitharu.kotatsu.base.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
@@ -25,6 +27,10 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
@Inject @Inject
lateinit var localMangaRepository: LocalMangaRepository lateinit var localMangaRepository: LocalMangaRepository
@Inject
@LocalStorageChanges
lateinit var localStorageChanges: MutableSharedFlow<LocalManga?>
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
isRunning = true isRunning = true
@@ -41,10 +47,7 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
startForeground() startForeground()
val mangaWithChapters = localMangaRepository.getDetails(manga) val mangaWithChapters = localMangaRepository.getDetails(manga)
localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds) localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds)
sendBroadcast( localStorageChanges.emit(LocalManga(manga))
Intent(DownloadService.ACTION_DOWNLOAD_COMPLETE)
.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false)),
)
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
} }

View File

@@ -9,11 +9,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
@@ -27,6 +26,8 @@ import org.koitharu.kotatsu.list.ui.model.ListHeader2
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
@@ -46,6 +47,7 @@ class LocalListViewModel @Inject constructor(
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val tagHighlighter: MangaTagHighlighter, private val tagHighlighter: MangaTagHighlighter,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
) : MangaListViewModel(settings), ListExtraProvider { ) : MangaListViewModel(settings), ListExtraProvider {
val onMangaRemoved = SingleLiveEvent<Unit>() val onMangaRemoved = SingleLiveEvent<Unit>()
@@ -83,7 +85,14 @@ class LocalListViewModel @Inject constructor(
init { init {
onRefresh() onRefresh()
watchDirectories() launchJob(Dispatchers.Default) {
localStorageChanges
.collectLatest {
if (refreshJob?.isActive != true) {
doRefresh()
}
}
}
} }
override fun onUpdateFilter(tags: Set<MangaTag>) { override fun onUpdateFilter(tags: Set<MangaTag>) {
@@ -108,21 +117,19 @@ class LocalListViewModel @Inject constructor(
} }
fun delete(ids: Set<Long>) { fun delete(ids: Set<Long>) {
launchLoadingJob { launchLoadingJob(Dispatchers.Default) {
withContext(Dispatchers.Default) { val itemsToRemove = checkNotNull(mangaList.value).filter { it.id in ids }
val itemsToRemove = checkNotNull(mangaList.value).filter { it.id in ids } for (manga in itemsToRemove) {
for (manga in itemsToRemove) { val original = repository.getRemoteManga(manga)
val original = repository.getRemoteManga(manga) repository.delete(manga) || throw IOException("Unable to delete file")
repository.delete(manga) || throw IOException("Unable to delete file") runCatchingCancellable {
runCatchingCancellable { historyRepository.deleteOrSwap(manga, original)
historyRepository.deleteOrSwap(manga, original) }
} mangaList.update { list ->
mangaList.update { list -> list?.filterNot { it.id == manga.id }
list?.filterNot { it.id == manga.id }
}
} }
} }
onMangaRemoved.call(Unit) onMangaRemoved.emitCall(Unit)
} }
} }
@@ -137,15 +144,6 @@ class LocalListViewModel @Inject constructor(
} }
} }
private fun watchDirectories() {
viewModelScope.launch(Dispatchers.Default) {
repository.watchReadableDirs()
.collectLatest {
doRefresh()
}
}
}
private fun createHeader(mangaList: List<Manga>, selectedTags: Set<MangaTag>, order: SortOrder): ListHeader2 { private fun createHeader(mangaList: List<Manga>, selectedTags: Set<MangaTag>, order: SortOrder): ListHeader2 {
val tags = HashMap<MangaTag, Int>() val tags = HashMap<MangaTag, Int>()
for (item in mangaList) { for (item in mangaList) {

View File

@@ -12,13 +12,12 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
class ExitCallback( class ExitCallback(
private val activity: BaseActivity<*>, private val activity: MainActivity,
private val snackbarHost: View, private val snackbarHost: View,
) : OnBackPressedCallback(false) { ) : OnBackPressedCallback(false) {

View File

@@ -41,6 +41,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.widgets.SlidingBottomNavigationView import org.koitharu.kotatsu.base.ui.widgets.SlidingBottomNavigationView
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.ActivityMainBinding import org.koitharu.kotatsu.databinding.ActivityMainBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
@@ -68,6 +69,7 @@ import org.koitharu.kotatsu.utils.ext.resolve
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.utils.ext.setNavigationBarTransparentCompat import org.koitharu.kotatsu.utils.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.utils.ext.tryLaunch import org.koitharu.kotatsu.utils.ext.tryLaunch
import javax.inject.Inject
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
private const val TAG_SEARCH = "search" private const val TAG_SEARCH = "search"
@@ -82,6 +84,9 @@ class MainActivity :
SearchSuggestionListener, SearchSuggestionListener,
MainNavigationDelegate.OnFragmentChangedListener { MainNavigationDelegate.OnFragmentChangedListener {
@Inject
lateinit var settings: AppSettings
private val viewModel by viewModels<MainViewModel>() private val viewModel by viewModels<MainViewModel>()
private val searchSuggestionViewModel by viewModels<SearchSuggestionViewModel>() private val searchSuggestionViewModel by viewModels<SearchSuggestionViewModel>()
private val voiceInputLauncher = registerForActivityResult(VoiceInputContract(), VoiceInputCallback()) private val voiceInputLauncher = registerForActivityResult(VoiceInputContract(), VoiceInputCallback())

View File

@@ -60,9 +60,9 @@ class MainViewModel @Inject constructor(
} }
fun openLastReader() { fun openLastReader() {
launchLoadingJob { launchLoadingJob(Dispatchers.Default) {
val manga = historyRepository.getLastOrNull() ?: throw EmptyHistoryException() val manga = historyRepository.getLastOrNull() ?: throw EmptyHistoryException()
onOpenReader.call(manga) onOpenReader.emitCall(manga)
} }
} }
} }

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.domain
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import androidx.annotation.AnyThread
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import androidx.collection.set import androidx.collection.set
import dagger.hilt.android.ActivityRetainedLifecycle import dagger.hilt.android.ActivityRetainedLifecycle
@@ -76,6 +77,7 @@ class PageLoader @Inject constructor(
return repository is RemoteMangaRepository && settings.isPagesPreloadEnabled() return repository is RemoteMangaRepository && settings.isPagesPreloadEnabled()
} }
@AnyThread
fun prefetch(pages: List<ReaderPage>) = loaderScope.launch { fun prefetch(pages: List<ReaderPage>) = loaderScope.launch {
prefetchLock.withLock { prefetchLock.withLock {
for (page in pages.asReversed()) { for (page in pages.asReversed()) {

View File

@@ -35,6 +35,7 @@ import org.koitharu.kotatsu.base.ui.BaseFullscreenActivity
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.exceptions.resolve.DialogErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.DialogErrorObserver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.databinding.ActivityReaderBinding import org.koitharu.kotatsu.databinding.ActivityReaderBinding
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -66,6 +67,9 @@ class ReaderActivity :
OnApplyWindowInsetsListener, OnApplyWindowInsetsListener,
IdlingDetector.Callback { IdlingDetector.Callback {
@Inject
lateinit var settings: AppSettings
private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this) private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this)
private val viewModel: ReaderViewModel by viewModels() private val viewModel: ReaderViewModel by viewModels()

View File

@@ -15,9 +15,6 @@ import androidx.annotation.AttrRes
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import com.google.android.material.R as materialR
import java.text.SimpleDateFormat
import java.util.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
@@ -25,6 +22,9 @@ import org.koitharu.kotatsu.utils.ext.getThemeColor
import org.koitharu.kotatsu.utils.ext.measureDimension import org.koitharu.kotatsu.utils.ext.measureDimension
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.resolveDp import org.koitharu.kotatsu.utils.ext.resolveDp
import java.text.SimpleDateFormat
import java.util.Date
import com.google.android.material.R as materialR
class ReaderInfoBarView @JvmOverloads constructor( class ReaderInfoBarView @JvmOverloads constructor(
context: Context, context: Context,
@@ -121,15 +121,14 @@ class ReaderInfoBarView @JvmOverloads constructor(
fun update(state: ReaderUiState?) { fun update(state: ReaderUiState?) {
text = if (state != null) { text = if (state != null) {
val percent = state.computePercent()
context.getString( context.getString(
R.string.reader_info_pattern, R.string.reader_info_pattern,
state.chapterNumber, state.chapterNumber,
state.chaptersTotal, state.chaptersTotal,
state.currentPage + 1, state.currentPage + 1,
state.totalPages, state.totalPages,
) + if (percent in 0f..1f) { ) + if (state.percent in 0f..1f) {
" " + context.getString(R.string.percent_string_pattern, (percent * 100).format()) " " + context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
} else { } else {
"" ""
} }

View File

@@ -4,6 +4,8 @@ import android.net.Uri
import android.util.LongSparseArray import android.util.LongSparseArray
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.annotation.MainThread
import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
@@ -13,6 +15,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -50,6 +53,7 @@ import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.emitValue
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.requireValue import org.koitharu.kotatsu.utils.ext.requireValue
@@ -80,6 +84,7 @@ class ReaderViewModel @Inject constructor(
private var loadingJob: Job? = null private var loadingJob: Job? = null
private var pageSaveJob: Job? = null private var pageSaveJob: Job? = null
private var bookmarkJob: Job? = null private var bookmarkJob: Job? = null
private var stateChangeJob: Job? = null
private val currentState = MutableStateFlow<ReaderState?>(savedStateHandle[ReaderActivity.EXTRA_STATE]) private val currentState = MutableStateFlow<ReaderState?>(savedStateHandle[ReaderActivity.EXTRA_STATE])
private val mangaData = MutableStateFlow(intent.manga) private val mangaData = MutableStateFlow(intent.manga)
private val chapters: LongSparseArray<MangaChapter> private val chapters: LongSparseArray<MangaChapter>
@@ -143,7 +148,7 @@ class ReaderViewModel @Inject constructor(
settings.observe() settings.observe()
.onEach { key -> .onEach { key ->
if (key == AppSettings.KEY_READER_SLIDER) notifyStateChanged() if (key == AppSettings.KEY_READER_SLIDER) notifyStateChanged()
}.launchIn(viewModelScope) }.launchIn(viewModelScope + Dispatchers.Default)
} }
fun reload() { fun reload() {
@@ -198,12 +203,12 @@ class ReaderViewModel @Inject constructor(
prevJob?.cancelAndJoin() prevJob?.cancelAndJoin()
try { try {
val dest = pageSaveHelper.savePage(pageLoader, page, saveLauncher) val dest = pageSaveHelper.savePage(pageLoader, page, saveLauncher)
onPageSaved.postCall(dest) onPageSaved.emitCall(dest)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTraceDebug() e.printStackTraceDebug()
onPageSaved.postCall(null) onPageSaved.emitCall(null)
} }
} }
} }
@@ -234,26 +239,31 @@ class ReaderViewModel @Inject constructor(
} }
} }
// TODO move to background? @MainThread
fun onCurrentPageChanged(position: Int) { fun onCurrentPageChanged(position: Int) {
val pages = content.value?.pages ?: return val prevJob = stateChangeJob
pages.getOrNull(position)?.let { page -> stateChangeJob = launchJob(Dispatchers.Default) {
currentState.update { cs -> prevJob?.cancelAndJoin()
cs?.copy(chapterId = page.chapterId, page = page.index) val pages = content.value?.pages ?: return@launchJob
pages.getOrNull(position)?.let { page ->
currentState.update { cs ->
cs?.copy(chapterId = page.chapterId, page = page.index)
}
}
notifyStateChanged()
if (pages.isEmpty() || loadingJob?.isActive == true) {
return@launchJob
}
ensureActive()
if (position <= BOUNDS_PAGE_OFFSET) {
loadPrevNextChapter(pages.first().chapterId, isNext = false)
}
if (position >= pages.lastIndex - BOUNDS_PAGE_OFFSET) {
loadPrevNextChapter(pages.last().chapterId, isNext = true)
}
if (pageLoader.isPrefetchApplicable()) {
pageLoader.prefetch(pages.trySublist(position + 1, position + PREFETCH_LIMIT))
} }
}
notifyStateChanged()
if (pages.isEmpty() || loadingJob?.isActive == true) {
return
}
if (position <= BOUNDS_PAGE_OFFSET) {
loadPrevNextChapter(pages.first().chapterId, isNext = false)
}
if (position >= pages.size - BOUNDS_PAGE_OFFSET) {
loadPrevNextChapter(pages.last().chapterId, isNext = true)
}
if (pageLoader.isPrefetchApplicable()) {
pageLoader.prefetch(pages.trySublist(position + 1, position + PREFETCH_LIMIT))
} }
} }
@@ -276,7 +286,7 @@ class ReaderViewModel @Inject constructor(
percent = computePercent(state.chapterId, state.page), percent = computePercent(state.chapterId, state.page),
) )
bookmarksRepository.addBookmark(bookmark) bookmarksRepository.addBookmark(bookmark)
onShowToast.postCall(R.string.bookmark_added) onShowToast.emitCall(R.string.bookmark_added)
} }
} }
@@ -313,7 +323,7 @@ class ReaderViewModel @Inject constructor(
val branch = chapters[currentState.value?.chapterId ?: 0L]?.branch val branch = chapters[currentState.value?.chapterId ?: 0L]?.branch
mangaData.value = manga.filterChapters(branch) mangaData.value = manga.filterChapters(branch)
readerMode.postValue(mode) readerMode.emitValue(mode)
chaptersLoader.loadSingleChapter(manga, requireNotNull(currentState.value).chapterId) chaptersLoader.loadSingleChapter(manga, requireNotNull(currentState.value).chapterId)
// save state // save state
@@ -324,14 +334,15 @@ class ReaderViewModel @Inject constructor(
} }
} }
notifyStateChanged() notifyStateChanged()
content.postValue(ReaderContent(chaptersLoader.snapshot(), currentState.value)) content.emitValue(ReaderContent(chaptersLoader.snapshot(), currentState.value))
} }
} }
@AnyThread
private fun loadPrevNextChapter(currentId: Long, isNext: Boolean) { private fun loadPrevNextChapter(currentId: Long, isNext: Boolean) {
loadingJob = launchLoadingJob(Dispatchers.Default) { loadingJob = launchLoadingJob(Dispatchers.Default) {
chaptersLoader.loadPrevNextChapter(mangaData.requireValue(), currentId, isNext) chaptersLoader.loadPrevNextChapter(mangaData.requireValue(), currentId, isNext)
content.postValue(ReaderContent(chaptersLoader.snapshot(), null)) content.emitValue(ReaderContent(chaptersLoader.snapshot(), null))
} }
} }
@@ -365,7 +376,7 @@ class ReaderViewModel @Inject constructor(
}.getOrDefault(defaultMode) }.getOrDefault(defaultMode)
} }
@AnyThread @WorkerThread
private fun notifyStateChanged() { private fun notifyStateChanged() {
val state = getCurrentState() val state = getCurrentState()
val chapter = state?.chapterId?.let(chapters::get) val chapter = state?.chapterId?.let(chapters::get)
@@ -373,10 +384,11 @@ class ReaderViewModel @Inject constructor(
mangaName = manga?.title, mangaName = manga?.title,
chapterName = chapter?.name, chapterName = chapter?.name,
chapterNumber = chapter?.number ?: 0, chapterNumber = chapter?.number ?: 0,
chaptersTotal = chapters.size(), chaptersTotal = manga?.getChapters(chapter?.branch)?.size ?: 0,
totalPages = if (chapter != null) chaptersLoader.getPagesCount(chapter.id) else 0, totalPages = if (chapter != null) chaptersLoader.getPagesCount(chapter.id) else 0,
currentPage = state?.page ?: 0, currentPage = state?.page ?: 0,
isSliderEnabled = settings.isReaderSliderEnabled, isSliderEnabled = settings.isReaderSliderEnabled,
percent = if (state != null) computePercent(state.chapterId, state.page) else PROGRESS_NONE,
) )
uiState.postValue(newState) uiState.postValue(newState)
} }

View File

@@ -28,6 +28,7 @@ import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import org.koitharu.kotatsu.utils.ext.decodeRegion import org.koitharu.kotatsu.utils.ext.decodeRegion
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.indicator
import org.koitharu.kotatsu.utils.ext.setValueRounded import org.koitharu.kotatsu.utils.ext.setValueRounded
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@@ -110,6 +111,7 @@ class ColorFilterConfigActivity :
.scale(Scale.FILL) .scale(Scale.FILL)
.decodeRegion() .decodeRegion()
.tag(preview.source) .tag(preview.source)
.indicator(listOf(binding.progressBefore, binding.progressAfter))
.error(R.drawable.ic_error_placeholder) .error(R.drawable.ic_error_placeholder)
.size(ViewSizeResolver(binding.imageViewBefore)) .size(ViewSizeResolver(binding.imageViewBefore))
.allowRgb565(false) .allowRgb565(false)

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity.Companion.EXTRA_MANGA import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity.Companion.EXTRA_MANGA
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.emitValue
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -43,7 +44,7 @@ class ColorFilterConfigViewModel @Inject constructor(
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
val repository = mangaRepositoryFactory.create(page.source) val repository = mangaRepositoryFactory.create(page.source)
val url = repository.getPageUrl(page) val url = repository.getPageUrl(page)
preview.postValue( preview.emitValue(
MangaPage( MangaPage(
id = page.id, id = page.id,
url = url, url = url,
@@ -71,7 +72,7 @@ class ColorFilterConfigViewModel @Inject constructor(
fun save() { fun save() {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
mangaDataRepository.saveColorFilter(manga, colorFilter.value) mangaDataRepository.saveColorFilter(manga, colorFilter.value)
onDismiss.postCall(Unit) onDismiss.emitCall(Unit)
} }
} }
} }

View File

@@ -7,17 +7,11 @@ data class ReaderUiState(
val chaptersTotal: Int, val chaptersTotal: Int,
val currentPage: Int, val currentPage: Int,
val totalPages: Int, val totalPages: Int,
val percent: Float,
private val isSliderEnabled: Boolean, private val isSliderEnabled: Boolean,
) { ) {
fun isSliderAvailable(): Boolean { fun isSliderAvailable(): Boolean {
return isSliderEnabled && totalPages > 1 && currentPage < totalPages return isSliderEnabled && totalPages > 1 && currentPage < totalPages
} }
fun computePercent(): Float {
val ppc = 1f / chaptersTotal
val chapterIndex = chapterNumber - 1
val pagePercent = (currentPage + 1) / totalPages.toFloat()
return ppc * chapterIndex + ppc * pagePercent
}
} }

View File

@@ -161,7 +161,7 @@ class RemoteListViewModel @Inject constructor(
e.printStackTraceDebug() e.printStackTraceDebug()
listError.value = e listError.value = e
if (!mangaList.value.isNullOrEmpty()) { if (!mangaList.value.isNullOrEmpty()) {
errorEvent.postCall(e) errorEvent.emitCall(e)
} }
} }
} }

View File

@@ -24,6 +24,7 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.emitValue
import org.koitharu.kotatsu.utils.ext.onFirst import org.koitharu.kotatsu.utils.ext.onFirst
import org.koitharu.kotatsu.utils.ext.require import org.koitharu.kotatsu.utils.ext.require
import javax.inject.Inject import javax.inject.Inject
@@ -51,22 +52,22 @@ class ScrobblerConfigViewModel @Inject constructor(
init { init {
scrobbler.user scrobbler.user
.onEach { user.postValue(it) } .onEach { user.emitValue(it) }
.launchIn(viewModelScope + Dispatchers.Default) .launchIn(viewModelScope + Dispatchers.Default)
} }
fun onAuthCodeReceived(authCode: String) { fun onAuthCodeReceived(authCode: String) {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
val newUser = scrobbler.authorize(authCode) val newUser = scrobbler.authorize(authCode)
user.postValue(newUser) user.emitValue(newUser)
} }
} }
fun logout() { fun logout() {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
scrobbler.logout() scrobbler.logout()
user.postValue(null) user.emitValue(null)
onLoggedOut.postCall(Unit) onLoggedOut.emitCall(Unit)
} }
} }

View File

@@ -23,6 +23,7 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.emitValue
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.require import org.koitharu.kotatsu.utils.ext.require
import org.koitharu.kotatsu.utils.ext.requireValue import org.koitharu.kotatsu.utils.ext.requireValue
@@ -135,7 +136,7 @@ class ScrobblingSelectorViewModel @Inject constructor(
} }
doneJob = launchJob(Dispatchers.Default) { doneJob = launchJob(Dispatchers.Default) {
currentScrobbler.linkManga(manga.id, targetId) currentScrobbler.linkManga(manga.id, targetId)
onClose.postCall(Unit) onClose.emitCall(Unit)
} }
} }
@@ -154,7 +155,7 @@ class ScrobblingSelectorViewModel @Inject constructor(
try { try {
val info = currentScrobbler.getScrobblingInfoOrNull(manga.id) val info = currentScrobbler.getScrobblingInfoOrNull(manga.id)
if (info != null) { if (info != null) {
selectedItemId.postValue(info.targetId) selectedItemId.emitValue(info.targetId)
} }
} finally { } finally {
loadList(append = false) loadList(append = false)

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.scrobbling.mal.data package org.koitharu.kotatsu.scrobbling.mal.data
import android.content.Context import android.content.Context
import android.util.Base64
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
@@ -20,7 +21,7 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
import org.koitharu.kotatsu.utils.PKCEGenerator import java.security.SecureRandom
private const val REDIRECT_URI = "kotatsu://mal-auth" private const val REDIRECT_URI = "kotatsu://mal-auth"
private const val BASE_WEB_URL = "https://myanimelist.net" private const val BASE_WEB_URL = "https://myanimelist.net"
@@ -35,7 +36,7 @@ class MALRepository(
) : ScrobblerRepository { ) : ScrobblerRepository {
private val clientId = context.getString(R.string.mal_clientId) private val clientId = context.getString(R.string.mal_clientId)
private var codeVerifier: String = getPKCEChallengeCode() private val codeVerifier: String by lazy(::generateCodeVerifier)
override val oauthUrl: String override val oauthUrl: String
get() = "$BASE_WEB_URL/v1/oauth2/authorize?" + get() = "$BASE_WEB_URL/v1/oauth2/authorize?" +
@@ -177,11 +178,6 @@ class MALRepository(
storage.clear() storage.clear()
} }
private fun getPKCEChallengeCode(): String {
codeVerifier = PKCEGenerator.generateCodeVerifier()
return codeVerifier
}
private fun jsonToManga(json: JSONObject): ScrobblerManga? { private fun jsonToManga(json: JSONObject): ScrobblerManga? {
for (i in 0 until json.length()) { for (i in 0 until json.length()) {
val node = json.getJSONObject("node") val node = json.getJSONObject("node")
@@ -210,4 +206,10 @@ class MALRepository(
avatar = json.getString("picture") ?: AVATAR_STUB, avatar = json.getString("picture") ?: AVATAR_STUB,
service = ScrobblerService.MAL, service = ScrobblerService.MAL,
) )
private fun generateCodeVerifier(): String {
val codeVerifier = ByteArray(50)
SecureRandom().nextBytes(codeVerifier)
return Base64.encodeToString(codeVerifier, Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE)
}
} }

View File

@@ -4,8 +4,8 @@ import android.annotation.SuppressLint
import android.app.SearchManager import android.app.SearchManager
import android.content.Context import android.content.Context
import android.provider.SearchRecentSuggestions import android.provider.SearchRecentSuggestions
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@@ -22,9 +22,9 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Singleton import javax.inject.Inject
@Singleton @Reusable
class MangaSearchRepository @Inject constructor( class MangaSearchRepository @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
private val db: MangaDatabase, private val db: MangaDatabase,

View File

@@ -17,6 +17,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.ListSelectionController import org.koitharu.kotatsu.base.ui.list.ListSelectionController
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.ActivitySearchMultiBinding import org.koitharu.kotatsu.databinding.ActivitySearchMultiBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
@@ -45,6 +46,9 @@ class MultiSearchActivity :
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
@Inject
lateinit var settings: AppSettings
private val viewModel by viewModels<MultiSearchViewModel>() private val viewModel by viewModels<MultiSearchViewModel>()
private lateinit var adapter: MultiSearchAdapter private lateinit var adapter: MultiSearchAdapter
private lateinit var selectionController: ListSelectionController private lateinit var selectionController: ListSelectionController

View File

@@ -28,6 +28,7 @@ import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.emitValue
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
@@ -96,7 +97,7 @@ class MultiSearchViewModel @Inject constructor(
listError.value = null listError.value = null
listData.value = emptyList() listData.value = emptyList()
loadingData.value = true loadingData.value = true
query.postValue(q) query.emitValue(q)
searchImpl(q) searchImpl(q)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e

View File

@@ -24,6 +24,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
import org.koitharu.kotatsu.utils.ext.emitValue
import javax.inject.Inject import javax.inject.Inject
private const val DEBOUNCE_TIMEOUT = 500L private const val DEBOUNCE_TIMEOUT = 500L
@@ -97,7 +98,7 @@ class SearchSuggestionViewModel @Inject constructor(
buildSearchSuggestion(searchQuery, hiddenSources) buildSearchSuggestion(searchQuery, hiddenSources)
}.distinctUntilChanged() }.distinctUntilChanged()
.onEach { .onEach {
suggestion.postValue(it) suggestion.emitValue(it)
}.launchIn(viewModelScope + Dispatchers.Default) }.launchIn(viewModelScope + Dispatchers.Default)
} }

View File

@@ -15,9 +15,9 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.databinding.DialogProgressBinding import org.koitharu.kotatsu.databinding.DialogProgressBinding
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.progress.Progress
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import kotlin.math.roundToInt
@AndroidEntryPoint @AndroidEntryPoint
class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() { class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
@@ -66,13 +66,13 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
dismiss() dismiss()
} }
private fun onProgressChanged(progress: Progress?) { private fun onProgressChanged(value: Float) {
with(binding.progressBar) { with(binding.progressBar) {
isIndeterminate = progress == null
isVisible = true isVisible = true
if (progress != null) { val wasIndeterminate = isIndeterminate
this.max = progress.total isIndeterminate = value < 0
this.progress = progress.value if (value >= 0) {
setProgressCompat((value * max).roundToInt(), !wasIndeterminate)
} }
} }
} }

View File

@@ -4,13 +4,12 @@ import android.content.Context
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import javax.inject.Inject
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.backup.BackupRepository import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.backup.BackupZipOutput import org.koitharu.kotatsu.core.backup.BackupZipOutput
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.progress.Progress import java.io.File
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class BackupViewModel @Inject constructor( class BackupViewModel @Inject constructor(
@@ -18,7 +17,7 @@ class BackupViewModel @Inject constructor(
@ApplicationContext context: Context, @ApplicationContext context: Context,
) : BaseViewModel() { ) : BaseViewModel() {
val progress = MutableLiveData<Progress?>(null) val progress = MutableLiveData(-1f)
val onBackupDone = SingleLiveEvent<File>() val onBackupDone = SingleLiveEvent<File>()
init { init {
@@ -26,18 +25,18 @@ class BackupViewModel @Inject constructor(
val file = BackupZipOutput(context).use { backup -> val file = BackupZipOutput(context).use { backup ->
backup.put(repository.createIndex()) backup.put(repository.createIndex())
progress.value = Progress(0, 3) progress.value = 0f
backup.put(repository.dumpHistory()) backup.put(repository.dumpHistory())
progress.value = Progress(1, 3) progress.value = 0.3f
backup.put(repository.dumpCategories()) backup.put(repository.dumpCategories())
progress.value = Progress(2, 3) progress.value = 0.6f
backup.put(repository.dumpFavourites()) backup.put(repository.dumpFavourites())
progress.value = Progress(3, 3) progress.value = 0.9f
backup.finish() backup.finish()
progress.value = null progress.value = 1f
backup.close() backup.close()
backup.file backup.file
} }

View File

@@ -15,7 +15,7 @@ import org.koitharu.kotatsu.core.backup.CompositeResult
import org.koitharu.kotatsu.databinding.DialogProgressBinding import org.koitharu.kotatsu.databinding.DialogProgressBinding
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
import org.koitharu.kotatsu.utils.progress.Progress import kotlin.math.roundToInt
@AndroidEntryPoint @AndroidEntryPoint
class RestoreDialogFragment : AlertDialogFragment<DialogProgressBinding>() { class RestoreDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
@@ -51,13 +51,13 @@ class RestoreDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
dismiss() dismiss()
} }
private fun onProgressChanged(progress: Progress?) { private fun onProgressChanged(value: Float) {
with(binding.progressBar) { with(binding.progressBar) {
isVisible = true isVisible = true
isIndeterminate = progress == null val wasIndeterminate = isIndeterminate
if (progress != null) { isIndeterminate = value < 0
this.max = progress.total if (value >= 0) {
this.progress = progress.value setProgressCompat((value * max).roundToInt(), !wasIndeterminate)
} }
} }
} }

View File

@@ -14,7 +14,6 @@ import org.koitharu.kotatsu.core.backup.BackupZipInput
import org.koitharu.kotatsu.core.backup.CompositeResult import org.koitharu.kotatsu.core.backup.CompositeResult
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.toUriOrNull import org.koitharu.kotatsu.utils.ext.toUriOrNull
import org.koitharu.kotatsu.utils.progress.Progress
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import javax.inject.Inject import javax.inject.Inject
@@ -26,7 +25,7 @@ class RestoreViewModel @Inject constructor(
@ApplicationContext context: Context, @ApplicationContext context: Context,
) : BaseViewModel() { ) : BaseViewModel() {
val progress = MutableLiveData<Progress?>(null) val progress = MutableLiveData(-1f)
val onRestoreDone = SingleLiveEvent<CompositeResult>() val onRestoreDone = SingleLiveEvent<CompositeResult>()
init { init {
@@ -47,16 +46,16 @@ class RestoreViewModel @Inject constructor(
try { try {
val result = CompositeResult() val result = CompositeResult()
progress.value = Progress(0, 3) progress.value = 0f
result += repository.restoreHistory(backup.getEntry(BackupEntry.HISTORY)) result += repository.restoreHistory(backup.getEntry(BackupEntry.HISTORY))
progress.value = Progress(1, 3) progress.value = 0.3f
result += repository.restoreCategories(backup.getEntry(BackupEntry.CATEGORIES)) result += repository.restoreCategories(backup.getEntry(BackupEntry.CATEGORIES))
progress.value = Progress(2, 3) progress.value = 0.6f
result += repository.restoreFavourites(backup.getEntry(BackupEntry.FAVOURITES)) result += repository.restoreFavourites(backup.getEntry(BackupEntry.FAVOURITES))
progress.value = Progress(3, 3) progress.value = 1f
onRestoreDone.call(result) onRestoreDone.call(result)
} finally { } finally {
backup.close() backup.close()

View File

@@ -82,7 +82,7 @@ class SourcesListViewModel @Inject constructor(
val rollback = ReversibleHandle { val rollback = ReversibleHandle {
setEnabled(source, true) setEnabled(source, true)
} }
onActionDone.postCall(ReversibleAction(R.string.source_disabled, rollback)) onActionDone.emitCall(ReversibleAction(R.string.source_disabled, rollback))
} }
buildList() buildList()
} }

View File

@@ -10,6 +10,7 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
import org.koitharu.kotatsu.core.db.removeObserverAsync import org.koitharu.kotatsu.core.db.removeObserverAsync
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.emitValue
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -31,7 +32,7 @@ class TrackerSettingsViewModel @Inject constructor(
private fun updateCategoriesCount() { private fun updateCategoriesCount() {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
categoriesCount.postValue(repository.getCategoriesCount()) categoriesCount.emitValue(repository.getCategoriesCount())
} }
} }

View File

@@ -1,16 +1,18 @@
package org.koitharu.kotatsu.shelf.domain package org.koitharu.kotatsu.shelf.domain
import dagger.Reusable
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
@@ -18,20 +20,22 @@ import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@Singleton @Reusable
class ShelfRepository @Inject constructor( class ShelfRepository @Inject constructor(
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
private val db: MangaDatabase, private val db: MangaDatabase,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
) { ) {
fun observeShelfContent(): Flow<ShelfContent> = combine( fun observeShelfContent(): Flow<ShelfContent> = combine(
@@ -43,16 +47,15 @@ class ShelfRepository @Inject constructor(
ShelfContent(history, favorites, updated, local) ShelfContent(history, favorites, updated, local)
} }
fun observeLocalManga(sortOrder: SortOrder): Flow<List<Manga>> { private fun observeLocalManga(sortOrder: SortOrder): Flow<List<Manga>> {
return flow { return localStorageChanges
emit(null) .onStart { emit(null) }
emitAll(localMangaRepository.watchReadableDirs()) .mapLatest {
}.mapLatest { localMangaRepository.getList(0, null, sortOrder)
localMangaRepository.getList(0, null, sortOrder) }.distinctUntilChanged()
}
} }
fun observeFavourites(): Flow<Map<FavouriteCategory, List<Manga>>> { private fun observeFavourites(): Flow<Map<FavouriteCategory, List<Manga>>> {
return db.favouriteCategoriesDao.observeAll() return db.favouriteCategoriesDao.observeAll()
.flatMapLatest { categories -> .flatMapLatest { categories ->
val cats = categories.filter { it.isVisibleInLibrary } val cats = categories.filter { it.isVisibleInLibrary }

View File

@@ -7,7 +7,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.util.ReversibleAction import org.koitharu.kotatsu.base.ui.util.ReversibleAction
@@ -60,10 +59,9 @@ class ShelfViewModel @Inject constructor(
repository.observeShelfContent(), repository.observeShelfContent(),
) { sections, isTrackerEnabled, isConnected, content -> ) { sections, isTrackerEnabled, isConnected, content ->
mapList(content, isTrackerEnabled, sections, isConnected) mapList(content, isTrackerEnabled, sections, isConnected)
}.debounce(500) }.catch { e ->
.catch { e -> emit(listOf(e.toErrorState(canRetry = false)))
emit(listOf(e.toErrorState(canRetry = false))) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
init { init {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
@@ -93,7 +91,7 @@ class ShelfViewModel @Inject constructor(
} }
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val handle = favouritesRepository.removeFromCategory(category.id, ids) val handle = favouritesRepository.removeFromCategory(category.id, ids)
onActionDone.postCall(ReversibleAction(R.string.removed_from_favourites, handle)) onActionDone.emitCall(ReversibleAction(R.string.removed_from_favourites, handle))
} }
} }
@@ -103,14 +101,14 @@ class ShelfViewModel @Inject constructor(
} }
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val handle = historyRepository.delete(ids) val handle = historyRepository.delete(ids)
onActionDone.postCall(ReversibleAction(R.string.removed_from_history, handle)) onActionDone.emitCall(ReversibleAction(R.string.removed_from_history, handle))
} }
} }
fun deleteLocal(ids: Set<Long>) { fun deleteLocal(ids: Set<Long>) {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
repository.deleteLocalManga(ids) repository.deleteLocalManga(ids)
onActionDone.postCall(ReversibleAction(R.string.removal_completed, null)) onActionDone.emitCall(ReversibleAction(R.string.removal_completed, null))
} }
} }
@@ -123,7 +121,7 @@ class ShelfViewModel @Inject constructor(
historyRepository.deleteAfter(minDate) historyRepository.deleteAfter(minDate)
R.string.removed_from_history R.string.removed_from_history
} }
onActionDone.postCall(ReversibleAction(stringRes, null)) onActionDone.emitCall(ReversibleAction(stringRes, null))
} }
} }

View File

@@ -44,7 +44,7 @@ class ShelfAdapter(
) )
.addDelegate(loadingStateAD()) .addDelegate(loadingStateAD())
.addDelegate(loadingFooterAD()) .addDelegate(loadingFooterAD())
.addDelegate(emptyHintAD(listener)) .addDelegate(emptyHintAD(coil, lifecycleOwner, listener))
.addDelegate(emptyStateListAD(coil, lifecycleOwner, listener)) .addDelegate(emptyStateListAD(coil, lifecycleOwner, listener))
.addDelegate(errorStateListAD(listener)) .addDelegate(errorStateListAD(listener))
} }

View File

@@ -1,12 +1,12 @@
package org.koitharu.kotatsu.sync.ui package org.koitharu.kotatsu.sync.ui
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.sync.data.SyncAuthApi import org.koitharu.kotatsu.sync.data.SyncAuthApi
import org.koitharu.kotatsu.sync.domain.SyncAuthResult import org.koitharu.kotatsu.sync.domain.SyncAuthResult
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SyncAuthViewModel @Inject constructor( class SyncAuthViewModel @Inject constructor(
@@ -19,7 +19,7 @@ class SyncAuthViewModel @Inject constructor(
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
val token = api.authenticate(email, password) val token = api.authenticate(email, password)
val result = SyncAuthResult(email, password, token) val result = SyncAuthResult(email, password, token)
onTokenObtained.postCall(result) onTokenObtained.emitCall(result)
} }
} }
} }

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.tracker.domain
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.room.withTransaction import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@@ -23,11 +24,10 @@ import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import java.util.Date import java.util.Date
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
private const val NO_ID = 0L private const val NO_ID = 0L
@Singleton @Reusable
class TrackingRepository @Inject constructor( class TrackingRepository @Inject constructor(
private val db: MangaDatabase, private val db: MangaDatabase,
) { ) {

View File

@@ -56,7 +56,7 @@ class FeedViewModel @Inject constructor(
if (clearCounters) { if (clearCounters) {
repository.clearCounters() repository.clearCounters()
} }
onFeedCleared.postCall(Unit) onFeedCleared.emitCall(Unit)
} }
} }

View File

@@ -8,6 +8,7 @@ import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.VISIBILITY_PUBLIC import androidx.core.app.NotificationCompat.VISIBILITY_PUBLIC
import androidx.core.app.NotificationCompat.VISIBILITY_SECRET import androidx.core.app.NotificationCompat.VISIBILITY_SECRET
import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
@@ -45,7 +46,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.tracker.domain.Tracker import org.koitharu.kotatsu.tracker.domain.Tracker
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.toBitmapOrNull import org.koitharu.kotatsu.utils.ext.toBitmapOrNull
import org.koitharu.kotatsu.utils.ext.trySetForeground import org.koitharu.kotatsu.utils.ext.trySetForeground
@@ -170,11 +170,12 @@ class TrackWorker @AssistedInject constructor(
setStyle(style) setStyle(style)
val intent = DetailsActivity.newIntent(applicationContext, manga) val intent = DetailsActivity.newIntent(applicationContext, manga)
setContentIntent( setContentIntent(
PendingIntent.getActivity( PendingIntentCompat.getActivity(
applicationContext, applicationContext,
id, id,
intent, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT,
false,
), ),
) )
setAutoCancel(true) setAutoCancel(true)

View File

@@ -1,45 +1,43 @@
package org.koitharu.kotatsu.utils package org.koitharu.kotatsu.utils
import androidx.collection.ArrayMap import androidx.collection.ArrayMap
import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import java.util.LinkedList
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
import kotlin.coroutines.resume
class CompositeMutex<T : Any> : Set<T> { class CompositeMutex<T : Any> : Set<T> {
private val data = ArrayMap<T, MutableList<CancellableContinuation<Unit>>>() private val state = ArrayMap<T, MutableStateFlow<Boolean>>()
private val mutex = Mutex() private val mutex = Mutex()
override val size: Int override val size: Int
get() = data.size get() = state.size
override fun contains(element: T): Boolean { override fun contains(element: T): Boolean {
return data.containsKey(element) return state.containsKey(element)
} }
override fun containsAll(elements: Collection<T>): Boolean { override fun containsAll(elements: Collection<T>): Boolean {
return elements.all { x -> data.containsKey(x) } return elements.all { x -> state.containsKey(x) }
} }
override fun isEmpty(): Boolean { override fun isEmpty(): Boolean {
return data.isEmpty return state.isEmpty
} }
override fun iterator(): Iterator<T> { override fun iterator(): Iterator<T> {
return data.keys.iterator() return state.keys.iterator()
} }
suspend fun lock(element: T) { suspend fun lock(element: T) {
while (coroutineContext.isActive) { while (coroutineContext.isActive) {
waitForRemoval(element) waitForRemoval(element)
mutex.withLock { mutex.withLock {
if (data[element] == null) { if (state[element] == null) {
data[element] = LinkedList<CancellableContinuation<Unit>>() state[element] = MutableStateFlow(false)
return return
} }
} }
@@ -47,23 +45,13 @@ class CompositeMutex<T : Any> : Set<T> {
} }
fun unlock(element: T) { fun unlock(element: T) {
val continuations = checkNotNull(data.remove(element)) { checkNotNull(state.remove(element)) {
"CompositeMutex is not locked for $element" "CompositeMutex is not locked for $element"
} }.value = true
continuations.forEach { c ->
if (c.isActive) {
c.resume(Unit)
}
}
} }
private suspend fun waitForRemoval(element: T) { private suspend fun waitForRemoval(element: T) {
val list = data[element] ?: return val flow = state[element] ?: return
suspendCancellableCoroutine { continuation -> flow.first { it }
list.add(continuation)
continuation.invokeOnCancellation {
list.remove(continuation)
}
}
} }
} }

View File

@@ -1,12 +1,18 @@
package org.koitharu.kotatsu.utils package org.koitharu.kotatsu.utils
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineScope
import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.* import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
private const val DEFAULT_TIMEOUT = 5_000L private const val DEFAULT_TIMEOUT = 5_000L
@@ -51,11 +57,16 @@ class FlowLiveData<T>(
private inner class Collector : FlowCollector<T> { private inner class Collector : FlowCollector<T> {
private var previousValue: Any? = value private var previousValue: Any? = value
private val dispatcher = Dispatchers.Main.immediate
override suspend fun emit(value: T) { override suspend fun emit(value: T) {
if (previousValue != value) { if (previousValue != value) {
previousValue = value previousValue = value
withContext(Dispatchers.Main.immediate) { if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
withContext(dispatcher) {
setValue(value)
}
} else {
setValue(value) setValue(value)
} }
} }

View File

@@ -1,27 +0,0 @@
package org.koitharu.kotatsu.utils
import android.content.Context
import android.content.res.Resources
object InternalResourceHelper {
fun getBoolean(context: Context, resName: String, defaultValue: Boolean): Boolean {
val id = getResourceId(resName, "bool")
return if (id != 0) {
context.createPackageContext("android", 0).resources.getBoolean(id)
} else {
defaultValue
}
}
/**
* Get resource id from system resources
* @param resName resource name to get
* @param type resource type of [resName] to get
* @return 0 if not available
*/
private fun getResourceId(resName: String, type: String): Int {
return Resources.getSystem().getIdentifier(resName, type, "android")
}
}

View File

@@ -1,16 +0,0 @@
package org.koitharu.kotatsu.utils
import android.util.Base64
import java.security.SecureRandom
object PKCEGenerator {
private const val PKCE_BASE64_ENCODE_SETTINGS = Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE
fun generateCodeVerifier(): String {
val codeVerifier = ByteArray(50)
SecureRandom().nextBytes(codeVerifier)
return Base64.encodeToString(codeVerifier, PKCE_BASE64_ENCODE_SETTINGS)
}
}

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