diff --git a/app/build.gradle b/app/build.gradle
index bff81dcb6..c131d00bd 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -15,8 +15,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 33
- versionCode 524
- versionName '4.4.8'
+ versionCode 534
+ versionName '5.0-b1'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -55,7 +55,6 @@ android {
'-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=coil.annotation.ExperimentalCoilApi',
- '-opt-in=com.google.android.material.badge.ExperimentalBadgeUtils',
]
}
lint {
@@ -79,7 +78,7 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
- implementation('com.github.KotatsuApp:kotatsu-parsers:2340100999') {
+ implementation('com.github.KotatsuApp:kotatsu-parsers:5e3fddb652') {
exclude group: 'org.json', module: 'json'
}
@@ -87,27 +86,27 @@ dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'androidx.appcompat:appcompat:1.6.1'
- implementation 'androidx.core:core-ktx:1.9.0'
- implementation 'androidx.activity:activity-ktx:1.6.1'
- implementation 'androidx.fragment:fragment-ktx:1.5.5'
- implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
- implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1'
- implementation 'androidx.lifecycle:lifecycle-service:2.5.1'
- implementation 'androidx.lifecycle:lifecycle-process:2.5.1'
+ implementation 'androidx.core:core-ktx:1.10.0'
+ implementation 'androidx.activity:activity-ktx:1.7.0'
+ implementation 'androidx.fragment:fragment-ktx:1.5.6'
+ implementation 'androidx.lifecycle:lifecycle-viewmodel-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-process:2.6.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
- implementation 'androidx.recyclerview:recyclerview:1.2.1'
+ implementation 'androidx.recyclerview:recyclerview:1.3.0'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.preference:preference-ktx:1.2.0'
- implementation 'androidx.work:work-runtime-ktx:2.8.0'
+ implementation 'androidx.work:work-runtime-ktx:2.8.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.8.0'
//noinspection LifecycleAnnotationProcessorWithJava8
- kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1'
+ kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
- implementation 'androidx.room:room-runtime:2.5.0'
- implementation 'androidx.room:room-ktx:2.5.0'
- kapt 'androidx.room:room-compiler:2.5.0'
+ implementation 'androidx.room:room-runtime:2.5.1'
+ implementation 'androidx.room:room-ktx:2.5.1'
+ kapt 'androidx.room:room-compiler:2.5.1'
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3'
@@ -121,8 +120,8 @@ dependencies {
implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0'
- implementation 'io.coil-kt:coil-base:2.2.2'
- implementation 'io.coil-kt:coil-svg:2.2.2'
+ implementation 'io.coil-kt:coil-base:2.3.0'
+ implementation 'io.coil-kt:coil-svg:2.3.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'
@@ -143,7 +142,7 @@ dependencies {
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
- androidTestImplementation 'androidx.room:room-testing:2.5.0'
+ androidTestImplementation 'androidx.room:room-testing:2.5.1'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.14.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.45'
diff --git a/app/src/debug/res/values/bools.xml b/app/src/debug/res/values/bools.xml
index b04fe1d72..36b9b0867 100644
--- a/app/src/debug/res/values/bools.xml
+++ b/app/src/debug/res/values/bools.xml
@@ -1,5 +1,4 @@
false
- true
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 58e59c8ff..deed5ae6b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -86,7 +86,17 @@
+ android:label="@string/settings">
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -154,7 +167,6 @@
android:foregroundServiceType="dataSync"
android:stopWithTask="false" />
-
diff --git a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt
index fdae94568..57ce0686c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt
@@ -23,6 +23,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext
+import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import javax.inject.Inject
@@ -125,6 +126,7 @@ class KotatsuApp : Application(), Configuration.Provider {
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
.setClassInstanceLimit(PagesCache::class.java, 1)
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
+ .setClassInstanceLimit(PageLoader::class.java, 1)
.penaltyLog()
.build(),
)
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt
index 1d6bb0ced..2c9d78960 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt
@@ -30,10 +30,12 @@ import java.io.File
import java.io.InputStream
import java.util.zip.ZipFile
import javax.inject.Inject
+import javax.inject.Singleton
import kotlin.math.roundToInt
private const val MIN_WEBTOON_RATIO = 2
+@Singleton
class MangaDataRepository @Inject constructor(
private val okHttpClient: OkHttpClient,
private val db: MangaDatabase,
@@ -126,7 +128,7 @@ class MangaDataRepository @Inject constructor(
.url(url)
.get()
.tag(MangaSource::class.java, page.source)
- .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
+ .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
.build()
okHttpClient.newCall(request).await().use {
runInterruptible(Dispatchers.IO) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaIntent.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaIntent.kt
index 20786cbf1..162bcb1c7 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaIntent.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaIntent.kt
@@ -3,6 +3,8 @@ package org.koitharu.kotatsu.base.domain
import android.content.Intent
import android.net.Uri
import android.os.Bundle
+import androidx.lifecycle.SavedStateHandle
+import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.getParcelableCompat
@@ -20,6 +22,12 @@ class MangaIntent private constructor(
uri = intent?.data,
)
+ constructor(savedStateHandle: SavedStateHandle) : this(
+ manga = savedStateHandle.get(KEY_MANGA)?.manga,
+ mangaId = savedStateHandle[KEY_ID] ?: ID_NONE,
+ uri = savedStateHandle[BaseActivity.EXTRA_DATA],
+ )
+
constructor(args: Bundle?) : this(
manga = args?.getParcelableCompat(KEY_MANGA)?.manga,
mangaId = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE,
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt
index 68eb5701a..f459d3719 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt
@@ -1,7 +1,7 @@
package org.koitharu.kotatsu.base.ui
+import android.content.Intent
import android.content.res.Configuration
-import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.view.MenuItem
@@ -59,6 +59,12 @@ abstract class BaseActivity :
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
insetsDelegate.handleImeInsets = true
+ putDataToExtras(intent)
+ }
+
+ override fun onNewIntent(intent: Intent?) {
+ putDataToExtras(intent)
+ super.onNewIntent(intent)
}
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
@@ -131,17 +137,12 @@ abstract class BaseActivity :
window.statusBarColor = getThemeColor(android.R.attr.statusBarColor)
}
- @Suppress("DEPRECATION", "DeprecatedCallableAddReplaceWith")
- @Deprecated("Should not be used")
- override fun onBackPressed() {
- if ( // https://issuetracker.google.com/issues/139738913
- Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&
- isTaskRoot &&
- supportFragmentManager.backStackEntryCount == 0
- ) {
- finishAfterTransition()
- } else {
- super.onBackPressed()
- }
+ private fun putDataToExtras(intent: Intent?) {
+ intent?.putExtra(EXTRA_DATA, intent.data)
+ }
+
+ companion object {
+
+ const val EXTRA_DATA = "data"
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt
index a7252ecb9..6207ce83f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.base.ui
import android.app.Dialog
import android.os.Bundle
-import android.util.DisplayMetrics
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -15,7 +14,8 @@ import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.AppBottomSheetDialog
-import org.koitharu.kotatsu.utils.ext.displayCompat
+import org.koitharu.kotatsu.utils.ext.findActivity
+import org.koitharu.kotatsu.utils.ext.getDisplaySize
import com.google.android.material.R as materialR
abstract class BaseBottomSheet : BottomSheetDialogFragment() {
@@ -41,21 +41,20 @@ abstract class BaseBottomSheet : BottomSheetDialogFragment() {
): View {
val binding = onInflateView(inflater, container)
viewBinding = binding
+ return binding.root
+ }
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
// Enforce max width for tablets
val width = resources.getDimensionPixelSize(R.dimen.bottom_sheet_width)
if (width > 0) {
behavior?.maxWidth = width
}
-
- // Set peek height to 50% display height
- requireContext().displayCompat?.let {
- val metrics = DisplayMetrics()
- it.getRealMetrics(metrics)
- behavior?.peekHeight = (metrics.heightPixels * 0.4).toInt()
+ // Set peek height to 40% display height
+ binding.root.context.findActivity()?.getDisplaySize()?.let {
+ behavior?.peekHeight = (it.height() * 0.4).toInt()
}
-
- return binding.root
}
override fun onDestroyView() {
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/OnTipCloseListener.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/OnTipCloseListener.kt
new file mode 100644
index 000000000..9c9721eef
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/OnTipCloseListener.kt
@@ -0,0 +1,6 @@
+package org.koitharu.kotatsu.base.ui.list
+
+interface OnTipCloseListener {
+
+ fun onCloseTip(tip: T)
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/DefaultTextWatcher.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/DefaultTextWatcher.kt
new file mode 100644
index 000000000..a382f488c
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/DefaultTextWatcher.kt
@@ -0,0 +1,13 @@
+package org.koitharu.kotatsu.base.ui.util
+
+import android.text.Editable
+import android.text.TextWatcher
+
+interface DefaultTextWatcher : TextWatcher {
+
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
+
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
+
+ override fun afterTextChanged(s: Editable?) = Unit
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ReversibleActionObserver.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ReversibleActionObserver.kt
new file mode 100644
index 000000000..04a332cd2
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ReversibleActionObserver.kt
@@ -0,0 +1,30 @@
+package org.koitharu.kotatsu.base.ui.util
+
+import android.view.View
+import androidx.lifecycle.Observer
+import com.google.android.material.snackbar.Snackbar
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.domain.reverseAsync
+import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
+import org.koitharu.kotatsu.utils.ext.findActivity
+
+class ReversibleActionObserver(
+ private val snackbarHost: View,
+) : Observer {
+
+ override fun onChanged(value: ReversibleAction?) {
+ if (value == null) {
+ return
+ }
+ val handle = value.handle
+ val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
+ val snackbar = Snackbar.make(snackbarHost, value.stringResId, length)
+ if (handle != null) {
+ snackbar.setAction(R.string.undo) { handle.reverseAsync() }
+ }
+ (snackbarHost.context.findActivity() as? BottomNavOwner)?.let {
+ snackbar.anchorView = it.bottomNav
+ }
+ snackbar.show()
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt
index 809add879..88398cbd0 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt
@@ -1,17 +1,20 @@
package org.koitharu.kotatsu.base.ui.widgets
+import android.annotation.SuppressLint
import android.content.Context
+import android.content.res.ColorStateList
import android.util.AttributeSet
import android.view.View.OnClickListener
-import androidx.annotation.DrawableRes
+import androidx.annotation.ColorRes
+import androidx.core.content.ContextCompat
+import androidx.core.content.res.getColorStateListOrThrow
import androidx.core.view.children
-import com.google.android.material.R as materialR
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable
import com.google.android.material.chip.ChipGroup
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.castOrNull
-import org.koitharu.kotatsu.utils.ext.getThemeColorStateList
+import com.google.android.material.R as materialR
class ChipsView @JvmOverloads constructor(
context: Context,
@@ -27,6 +30,9 @@ class ChipsView @JvmOverloads constructor(
private val chipOnCloseListener = OnClickListener {
onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag)
}
+ private val defaultChipStrokeColor: ColorStateList
+ private val defaultChipTextColor: ColorStateList
+ private val defaultChipIconTint: ColorStateList
var onChipClickListener: OnChipClickListener? = null
set(value) {
field = value
@@ -40,6 +46,15 @@ class ChipsView @JvmOverloads constructor(
children.forEach { (it as? Chip)?.isCloseIconVisible = isCloseIconVisible }
}
+ init {
+ @SuppressLint("CustomViewStyleable")
+ val a = context.obtainStyledAttributes(null, materialR.styleable.Chip, 0, R.style.Widget_Kotatsu_Chip)
+ defaultChipStrokeColor = a.getColorStateListOrThrow(materialR.styleable.Chip_chipStrokeColor)
+ defaultChipTextColor = a.getColorStateListOrThrow(materialR.styleable.Chip_android_textColor)
+ defaultChipIconTint = a.getColorStateListOrThrow(materialR.styleable.Chip_chipIconTint)
+ a.recycle()
+ }
+
override fun requestLayout() {
if (isLayoutSuppressedCompat) {
isLayoutCalledOnSuppressed = true
@@ -75,12 +90,15 @@ class ChipsView @JvmOverloads constructor(
private fun bindChip(chip: Chip, model: ChipModel) {
chip.text = model.title
- if (model.icon == 0) {
- chip.isChipIconVisible = false
+ val tint = if (model.tint == 0) {
+ null
} else {
- chip.isChipIconVisible = true
- chip.setChipIconResource(model.icon)
+ ContextCompat.getColorStateList(context, model.tint)
}
+ chip.chipIconTint = tint ?: defaultChipIconTint
+ chip.checkedIconTint = tint ?: defaultChipIconTint
+ chip.chipStrokeColor = tint ?: defaultChipStrokeColor
+ chip.setTextColor(tint ?: defaultChipTextColor)
chip.isClickable = onChipClickListener != null || model.isCheckable
chip.isCheckable = model.isCheckable
chip.isChecked = model.isChecked
@@ -92,8 +110,9 @@ class ChipsView @JvmOverloads constructor(
val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip)
chip.setChipDrawable(drawable)
chip.isCheckedIconVisible = true
+ chip.isChipIconVisible = false
chip.setCheckedIconResource(R.drawable.ic_check)
- chip.checkedIconTint = context.getThemeColorStateList(materialR.attr.colorControlNormal)
+ chip.checkedIconTint = defaultChipIconTint
chip.isCloseIconVisible = onChipCloseClickListener != null
chip.setOnCloseIconClickListener(chipOnCloseListener)
chip.setEnsureMinTouchTargetSize(false)
@@ -113,7 +132,7 @@ class ChipsView @JvmOverloads constructor(
}
class ChipModel(
- @DrawableRes val icon: Int,
+ @ColorRes val tint: Int,
val title: CharSequence,
val isCheckable: Boolean,
val isChecked: Boolean,
@@ -126,7 +145,7 @@ class ChipsView @JvmOverloads constructor(
other as ChipModel
- if (icon != other.icon) return false
+ if (tint != other.tint) return false
if (title != other.title) return false
if (isCheckable != other.isCheckable) return false
if (isChecked != other.isChecked) return false
@@ -136,7 +155,7 @@ class ChipsView @JvmOverloads constructor(
}
override fun hashCode(): Int {
- var result = icon
+ var result = tint.hashCode()
result = 31 * result + title.hashCode()
result = 31 * result + isCheckable.hashCode()
result = 31 * result + isChecked.hashCode()
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SegmentedBarView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SegmentedBarView.kt
index aed43d75b..1125b7839 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SegmentedBarView.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SegmentedBarView.kt
@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.base.ui.widgets
+import android.animation.Animator
+import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Outline
@@ -7,48 +9,34 @@ import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
import android.view.ViewOutlineProvider
+import android.view.animation.DecelerateInterpolator
import androidx.annotation.ColorInt
import androidx.annotation.FloatRange
-import androidx.core.graphics.ColorUtils
-import com.google.android.material.R as materialR
-import kotlin.random.Random
import org.koitharu.kotatsu.parsers.util.replaceWith
+import org.koitharu.kotatsu.utils.ext.getAnimationDuration
import org.koitharu.kotatsu.utils.ext.getThemeColor
+import org.koitharu.kotatsu.utils.ext.isAnimationsEnabled
import org.koitharu.kotatsu.utils.ext.resolveDp
+import com.google.android.material.R as materialR
class SegmentedBarView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
-) : View(context, attrs, defStyleAttr) {
+) : View(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener, Animator.AnimatorListener {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val segmentsData = ArrayList()
private val segmentsSizes = ArrayList()
private val outlineColor = context.getThemeColor(materialR.attr.colorOutline)
private var cornerSize = 0f
-
- var segments: List
- get() = segmentsData
- set(value) {
- segmentsData.replaceWith(value)
- updateSizes()
- invalidate()
- }
+ private var scaleFactor = 1f
+ private var scaleAnimator: ValueAnimator? = null
init {
paint.strokeWidth = context.resources.resolveDp(1f)
outlineProvider = OutlineProvider()
clipToOutline = true
-
- if (isInEditMode) {
- segments = List(Random.nextInt(3, 5)) {
- Segment(
- percent = Random.nextFloat(),
- color = ColorUtils.HSLToColor(floatArrayOf(Random.nextInt(0, 360).toFloat(), 0.5f, 0.5f)),
- )
- }
- }
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
@@ -79,12 +67,56 @@ class SegmentedBarView @JvmOverloads constructor(
canvas.drawRoundRect(0f, 0f, w, height.toFloat(), cornerSize, cornerSize, paint)
}
+ override fun onAnimationStart(animation: Animator) = Unit
+
+ override fun onAnimationEnd(animation: Animator) {
+ if (scaleAnimator === animation) {
+ scaleAnimator = null
+ }
+ }
+
+ override fun onAnimationUpdate(animation: ValueAnimator) {
+ scaleFactor = animation.animatedValue as Float
+ updateSizes()
+ invalidate()
+ }
+
+ override fun onAnimationCancel(animation: Animator) = Unit
+
+ override fun onAnimationRepeat(animation: Animator) = Unit
+
+ fun animateSegments(value: List) {
+ scaleAnimator?.cancel()
+ segmentsData.replaceWith(value)
+ if (!context.isAnimationsEnabled) {
+ scaleAnimator = null
+ scaleFactor = 1f
+ updateSizes()
+ invalidate()
+ return
+ }
+ scaleFactor = 0f
+ updateSizes()
+ invalidate()
+ val animator = ValueAnimator.ofFloat(0f, 1f)
+ animator.duration = context.getAnimationDuration(android.R.integer.config_longAnimTime)
+ animator.interpolator = DecelerateInterpolator()
+ animator.addUpdateListener(this@SegmentedBarView)
+ animator.addListener(this@SegmentedBarView)
+ scaleAnimator = animator
+ animator.start()
+ }
+
private fun updateSizes() {
segmentsSizes.clear()
segmentsSizes.ensureCapacity(segmentsData.size + 1)
var w = width.toFloat()
- for (segment in segmentsData) {
- val segmentWidth = (w * segment.percent).coerceAtLeast(cornerSize)
+ val maxScale = (scaleFactor * (segmentsData.size - 1)).coerceAtLeast(1f)
+ for ((index, segment) in segmentsData.withIndex()) {
+ val scale = (scaleFactor * (index + 1) / maxScale).coerceAtMost(1f)
+ val segmentWidth = (w * segment.percent).coerceAtLeast(
+ if (index == 0) height.toFloat() else cornerSize,
+ ) * scale
segmentsSizes.add(segmentWidth)
w -= segmentWidth
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/TwoLinesItemView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/TwoLinesItemView.kt
new file mode 100644
index 000000000..f7f8d44e1
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/TwoLinesItemView.kt
@@ -0,0 +1,104 @@
+package org.koitharu.kotatsu.base.ui.widgets
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.res.ColorStateList
+import android.content.res.TypedArray
+import android.graphics.Color
+import android.graphics.drawable.InsetDrawable
+import android.graphics.drawable.RippleDrawable
+import android.graphics.drawable.ShapeDrawable
+import android.graphics.drawable.shapes.RoundRectShape
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.widget.LinearLayout
+import androidx.annotation.AttrRes
+import androidx.annotation.DrawableRes
+import androidx.core.content.ContextCompat
+import androidx.core.content.withStyledAttributes
+import androidx.core.view.updateLayoutParams
+import androidx.core.widget.ImageViewCompat
+import androidx.core.widget.TextViewCompat
+import com.google.android.material.ripple.RippleUtils
+import com.google.android.material.shape.MaterialShapeDrawable
+import com.google.android.material.shape.ShapeAppearanceModel
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.databinding.ViewTwoLinesItemBinding
+import org.koitharu.kotatsu.utils.ext.resolveDp
+
+@SuppressLint("RestrictedApi")
+class TwoLinesItemView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ @AttrRes defStyleAttr: Int = 0,
+) : LinearLayout(context, attrs, defStyleAttr) {
+
+ private val binding = ViewTwoLinesItemBinding.inflate(LayoutInflater.from(context), this)
+
+ init {
+ var textColors: ColorStateList? = null
+ context.withStyledAttributes(
+ set = attrs,
+ attrs = R.styleable.TwoLinesItemView,
+ defStyleAttr = defStyleAttr,
+ defStyleRes = R.style.Widget_Kotatsu_TwoLinesItemView,
+ ) {
+ val itemRippleColor = getRippleColor(context)
+ val shape = createShapeDrawable(this)
+ val roundCorners = FloatArray(8) { resources.resolveDp(16f) }
+ background = RippleDrawable(
+ RippleUtils.sanitizeRippleDrawableColor(itemRippleColor),
+ shape,
+ ShapeDrawable(RoundRectShape(roundCorners, null, null)),
+ )
+ val drawablePadding = getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_drawablePadding, 0)
+ binding.layoutText.updateLayoutParams { marginStart = drawablePadding }
+ setIconResource(getResourceId(R.styleable.TwoLinesItemView_icon, 0))
+ binding.title.text = getText(R.styleable.TwoLinesItemView_title)
+ binding.subtitle.text = getText(R.styleable.TwoLinesItemView_subtitle)
+ textColors = getColorStateList(R.styleable.TwoLinesItemView_android_textColor)
+ val textAppearanceFallback = androidx.appcompat.R.style.TextAppearance_AppCompat
+ TextViewCompat.setTextAppearance(
+ binding.title,
+ getResourceId(R.styleable.TwoLinesItemView_titleTextAppearance, textAppearanceFallback),
+ )
+ TextViewCompat.setTextAppearance(
+ binding.subtitle,
+ getResourceId(R.styleable.TwoLinesItemView_subtitleTextAppearance, textAppearanceFallback),
+ )
+ }
+ if (textColors == null) {
+ textColors = binding.title.textColors
+ }
+ binding.title.setTextColor(textColors)
+ binding.subtitle.setTextColor(textColors)
+ ImageViewCompat.setImageTintList(binding.icon, textColors)
+ }
+
+ fun setIconResource(@DrawableRes resId: Int) {
+ val icon = if (resId != 0) ContextCompat.getDrawable(context, resId) else null
+ binding.icon.setImageDrawable(icon)
+ }
+
+ private fun createShapeDrawable(ta: TypedArray): InsetDrawable {
+ val shapeAppearance = ShapeAppearanceModel.builder(
+ context,
+ ta.getResourceId(R.styleable.TwoLinesItemView_shapeAppearance, 0),
+ ta.getResourceId(R.styleable.TwoLinesItemView_shapeAppearanceOverlay, 0),
+ ).build()
+ val shapeDrawable = MaterialShapeDrawable(shapeAppearance)
+ shapeDrawable.fillColor = ta.getColorStateList(R.styleable.TwoLinesItemView_backgroundFillColor)
+ return InsetDrawable(
+ shapeDrawable,
+ ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetLeft, 0),
+ ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetTop, 0),
+ ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetRight, 0),
+ ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetBottom, 0),
+ )
+ }
+
+ private fun getRippleColor(context: Context): ColorStateList {
+ return ContextCompat.getColorStateList(context, R.color.selector_overlay)
+ ?: ColorStateList.valueOf(Color.TRANSPARENT)
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt
index 9ba79d92b..8162e4016 100644
--- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt
@@ -17,7 +17,9 @@ import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.mapItems
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
+import javax.inject.Singleton
+@Singleton
class BookmarksRepository @Inject constructor(
private val db: MangaDatabase,
) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksFragment.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksFragment.kt
index 870996d83..06b3dbb36 100644
--- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksFragment.kt
@@ -1,7 +1,12 @@
package org.koitharu.kotatsu.bookmarks.ui
import android.os.Bundle
-import android.view.*
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
@@ -10,7 +15,6 @@ import androidx.fragment.app.viewModels
import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
-import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.base.ui.BaseFragment
@@ -24,6 +28,7 @@ import org.koitharu.kotatsu.bookmarks.data.ids
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksGroupAdapter
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
+import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
@@ -32,9 +37,9 @@ import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity
-import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.invalidateNestedItemDecorations
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
+import javax.inject.Inject
@AndroidEntryPoint
class BookmarksFragment :
@@ -76,7 +81,7 @@ class BookmarksFragment :
binding.recyclerView.addItemDecoration(spacingDecoration)
viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
- viewModel.onError.observe(viewLifecycleOwner, ::onError)
+ viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
}
@@ -90,6 +95,7 @@ class BookmarksFragment :
if (selectionController?.onItemClick(item.manga, item.pageId) != true) {
val intent = ReaderActivity.newIntent(view.context, item)
startActivity(intent, scaleUpActivityOptionsOf(view).toBundle())
+ Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
}
@@ -132,6 +138,7 @@ class BookmarksFragment :
mode.finish()
true
}
+
else -> false
}
}
@@ -154,14 +161,6 @@ class BookmarksFragment :
adapter?.items = list
}
- private fun onError(e: Throwable) {
- Snackbar.make(
- binding.recyclerView,
- e.getDisplayMessage(resources),
- Snackbar.LENGTH_SHORT,
- ).show()
- }
-
private fun onActionDone(action: ReversibleAction) {
val handle = action.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksViewModel.kt
index 87adf5a1c..7ba0b2a79 100644
--- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksViewModel.kt
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.bookmarks.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
@@ -18,7 +17,8 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.SingleLiveEvent
-import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+import org.koitharu.kotatsu.utils.asFlowLiveData
+import javax.inject.Inject
@HiltViewModel
class BookmarksViewModel @Inject constructor(
@@ -43,7 +43,7 @@ class BookmarksViewModel @Inject constructor(
}
}
.catch { e -> emit(listOf(e.toErrorState(canRetry = false))) }
- .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
+ .asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
fun removeBookmarks(ids: Map>) {
launchJob(Dispatchers.Default) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt
index 87ee3cbfc..1ffa3bac3 100644
--- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt
@@ -8,9 +8,12 @@ import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
+import org.koitharu.kotatsu.utils.ext.decodeRegion
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.source
+import org.koitharu.kotatsu.utils.image.CoverSizeResolver
fun bookmarkListAD(
coil: ImageLoader,
@@ -25,12 +28,14 @@ fun bookmarkListAD(
binding.root.setOnLongClickListener(listener)
bind {
- binding.imageViewThumb.newImageRequest(item.imageUrl, item.manga.source)?.run {
+ binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageUrl)?.run {
+ size(CoverSizeResolver(binding.imageViewThumb))
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
- lifecycle(lifecycleOwner)
+ decodeRegion(item.scroll)
+ source(item.manga.source)
enqueueWith(coil)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksGroupAD.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksGroupAD.kt
index 47f7dd04a..a4d33d0eb 100644
--- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksGroupAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksGroupAD.kt
@@ -18,6 +18,8 @@ import org.koitharu.kotatsu.utils.ext.clearItemDecorations
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.source
+import org.koitharu.kotatsu.utils.image.CoverSizeResolver
fun bookmarksGroupAD(
coil: ImageLoader,
@@ -48,12 +50,13 @@ fun bookmarksGroupAD(
binding.recyclerView.addItemDecoration(spacingDecoration)
selectionController.attachToRecyclerView(item.manga, binding.recyclerView)
}
- binding.imageViewCover.newImageRequest(item.manga.coverUrl, item.manga.source)?.run {
+ binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
- lifecycle(lifecycleOwner)
+ size(CoverSizeResolver(binding.imageViewCover))
+ source(item.manga.source)
enqueueWith(coil)
}
binding.textViewTitle.text = item.manga.title
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksGroupAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksGroupAdapter.kt
index e241c8d17..a73d0a0c1 100644
--- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksGroupAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksGroupAdapter.kt
@@ -5,14 +5,18 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
-import kotlin.jvm.internal.Intrinsics
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
-import org.koitharu.kotatsu.list.ui.adapter.*
+import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
+import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
+import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
+import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
+import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
+import kotlin.jvm.internal.Intrinsics
class BookmarksGroupAdapter(
coil: ImageLoader,
@@ -38,7 +42,7 @@ class BookmarksGroupAdapter(
)
.addDelegate(loadingStateAD())
.addDelegate(loadingFooterAD())
- .addDelegate(emptyStateListAD(coil, listener))
+ .addDelegate(emptyStateListAD(coil, lifecycleOwner, listener))
.addDelegate(errorStateListAD(listener))
}
@@ -49,6 +53,7 @@ class BookmarksGroupAdapter(
oldItem is BookmarksGroup && newItem is BookmarksGroup -> {
oldItem.manga.id == newItem.manga.id
}
+
else -> oldItem.javaClass == newItem.javaClass
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/browser/ProgressChromeClient.kt b/app/src/main/java/org/koitharu/kotatsu/browser/ProgressChromeClient.kt
index 27e7f9b38..55bdc9707 100644
--- a/app/src/main/java/org/koitharu/kotatsu/browser/ProgressChromeClient.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/browser/ProgressChromeClient.kt
@@ -2,14 +2,13 @@ package org.koitharu.kotatsu.browser
import android.webkit.WebChromeClient
import android.webkit.WebView
-import android.widget.ProgressBar
import androidx.core.view.isVisible
-import org.koitharu.kotatsu.utils.ext.setProgressCompat
+import com.google.android.material.progressindicator.BaseProgressIndicator
private const val PROGRESS_MAX = 100
class ProgressChromeClient(
- private val progressIndicator: ProgressBar,
+ private val progressIndicator: BaseProgressIndicator<*>,
) : WebChromeClient() {
init {
@@ -28,4 +27,4 @@ class ProgressChromeClient(
progressIndicator.isIndeterminate = true
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt
index 1a70db008..34f02003c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt
@@ -20,14 +20,13 @@ import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding
-import org.koitharu.kotatsu.utils.ext.stringArgument
import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
@AndroidEntryPoint
class CloudFlareDialog : AlertDialogFragment(), CloudFlareCallback {
- private val url by stringArgument(ARG_URL)
+ private lateinit var url: String
private val pendingResult = Bundle(1)
@Inject
@@ -35,6 +34,11 @@ class CloudFlareDialog : AlertDialogFragment(), Cloud
private var onBackPressedCallback: WebViewBackPressedCallback? = null
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ url = requireArguments().getString(ARG_URL).orEmpty()
+ }
+
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -50,12 +54,12 @@ class CloudFlareDialog : AlertDialogFragment(), Cloud
databaseEnabled = true
userAgentString = arguments?.getString(ARG_UA) ?: CommonHeadersInterceptor.userAgentChrome
}
- binding.webView.webViewClient = CloudFlareClient(cookieJar, this, url.orEmpty())
+ binding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
CookieManager.getInstance().setAcceptThirdPartyCookies(binding.webView, true)
- if (url.isNullOrEmpty()) {
+ if (url.isEmpty()) {
dismissAllowingStateLoss()
} else {
- binding.webView.loadUrl(url.orEmpty())
+ binding.webView.loadUrl(url)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt
index a02e42b42..042f57865 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt
@@ -86,6 +86,7 @@ interface AppModule {
fun provideOkHttpClient(
localStorageManager: LocalStorageManager,
commonHeadersInterceptor: CommonHeadersInterceptor,
+ mirrorSwitchInterceptor: MirrorSwitchInterceptor,
cookieJar: CookieJar,
settings: AppSettings,
): OkHttpClient {
@@ -103,6 +104,7 @@ interface AppModule {
addInterceptor(GZipInterceptor())
addInterceptor(commonHeadersInterceptor)
addInterceptor(CloudFlareInterceptor())
+ addInterceptor(mirrorSwitchInterceptor)
if (BuildConfig.DEBUG) {
addInterceptor(CurlLoggingInterceptor())
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/DialogErrorObserver.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/DialogErrorObserver.kt
new file mode 100644
index 000000000..c3bc2f893
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/DialogErrorObserver.kt
@@ -0,0 +1,66 @@
+package org.koitharu.kotatsu.core.exceptions.resolve
+
+import android.content.DialogInterface
+import android.view.View
+import androidx.core.util.Consumer
+import androidx.fragment.app.Fragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.ui.ErrorDetailsDialog
+import org.koitharu.kotatsu.parsers.exception.ParseException
+import org.koitharu.kotatsu.utils.ext.getDisplayMessage
+
+class DialogErrorObserver(
+ host: View,
+ fragment: Fragment?,
+ resolver: ExceptionResolver?,
+ private val onResolved: Consumer?,
+) : ErrorObserver(host, fragment, resolver, onResolved) {
+
+ constructor(
+ host: View,
+ fragment: Fragment?,
+ ) : this(host, fragment, null, null)
+
+ override fun onChanged(value: Throwable?) {
+ if (value == null) {
+ return
+ }
+ val listener = DialogListener(value)
+ val dialogBuilder = MaterialAlertDialogBuilder(activity ?: host.context)
+ .setMessage(value.getDisplayMessage(host.context.resources))
+ .setNegativeButton(R.string.close, listener)
+ .setOnCancelListener(listener)
+ if (canResolve(value)) {
+ dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener)
+ } else if (value is ParseException) {
+ val fm = fragmentManager
+ if (fm != null) {
+ dialogBuilder.setPositiveButton(R.string.details) { _, _ ->
+ ErrorDetailsDialog.show(fm, value, value.url)
+ }
+ }
+ }
+ val dialog = dialogBuilder.create()
+ if (activity != null) {
+ dialog.setOwnerActivity(activity)
+ }
+ dialog.show()
+ }
+
+ private inner class DialogListener(
+ private val error: Throwable,
+ ) : DialogInterface.OnClickListener, DialogInterface.OnCancelListener {
+
+ override fun onClick(dialog: DialogInterface?, which: Int) {
+ when (which) {
+ DialogInterface.BUTTON_NEGATIVE -> onResolved?.accept(false)
+ DialogInterface.BUTTON_POSITIVE -> resolve(error)
+ }
+ }
+
+ override fun onCancel(dialog: DialogInterface?) {
+ onResolved?.accept(false)
+ }
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/ErrorObserver.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/ErrorObserver.kt
new file mode 100644
index 000000000..e41b65955
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/ErrorObserver.kt
@@ -0,0 +1,44 @@
+package org.koitharu.kotatsu.core.exceptions.resolve
+
+import android.view.View
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.util.Consumer
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentManager
+import androidx.lifecycle.LifecycleCoroutineScope
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.Observer
+import androidx.lifecycle.coroutineScope
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import org.koitharu.kotatsu.utils.ext.findActivity
+import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
+
+abstract class ErrorObserver(
+ protected val host: View,
+ protected val fragment: Fragment?,
+ private val resolver: ExceptionResolver?,
+ private val onResolved: Consumer?,
+) : Observer {
+
+ protected val activity = host.context.findActivity()
+
+ private val lifecycleScope: LifecycleCoroutineScope
+ get() = checkNotNull(fragment?.viewLifecycleScope ?: (activity as? LifecycleOwner)?.lifecycle?.coroutineScope)
+
+ protected val fragmentManager: FragmentManager?
+ get() = fragment?.childFragmentManager ?: (activity as? AppCompatActivity)?.supportFragmentManager
+
+ protected fun canResolve(error: Throwable): Boolean {
+ return resolver != null && ExceptionResolver.canResolve(error)
+ }
+
+ protected fun resolve(error: Throwable) {
+ lifecycleScope.launch {
+ val isResolved = resolver?.resolve(error) ?: false
+ if (isActive) {
+ onResolved?.accept(isResolved)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt
index 459986d17..75d466bbb 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt
@@ -1,27 +1,23 @@
package org.koitharu.kotatsu.core.exceptions.resolve
-import android.content.ClipData
-import android.content.ClipboardManager
-import android.content.Context
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.StringRes
import androidx.collection.ArrayMap
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Headers
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
+import org.koitharu.kotatsu.core.ui.ErrorDetailsDialog
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import org.koitharu.kotatsu.utils.TaggedActivityResult
-import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.isSuccess
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
@@ -43,11 +39,14 @@ class ExceptionResolver private constructor(
sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this)
}
- override fun onActivityResult(result: TaggedActivityResult?) {
- result ?: return
+ override fun onActivityResult(result: TaggedActivityResult) {
continuations.remove(result.tag)?.resume(result.isSuccess)
}
+ fun showDetails(e: Throwable, url: String?) {
+ ErrorDetailsDialog.show(getFragmentManager(), e, url)
+ }
+
suspend fun resolve(e: Throwable): Boolean = when (e) {
is CloudFlareProtectedException -> resolveCF(e.url, e.headers)
is AuthRequiredException -> resolveAuthException(e.source)
@@ -100,21 +99,5 @@ class ExceptionResolver private constructor(
}
fun canResolve(e: Throwable) = getResolveStringId(e) != 0
-
- fun showDetails(context: Context, e: Throwable) {
- val stackTrace = e.stackTraceToString()
- val dialog = MaterialAlertDialogBuilder(context)
- .setTitle(e.getDisplayMessage(context.resources))
- .setMessage(stackTrace)
- .setPositiveButton(androidx.preference.R.string.copy) { _, _ ->
- val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
- clipboardManager.setPrimaryClip(
- ClipData.newPlainText(context.getString(R.string.error), stackTrace),
- )
- }
- .setNegativeButton(R.string.close, null)
- .create()
- dialog.show()
- }
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt
new file mode 100644
index 000000000..fb3cea7d9
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt
@@ -0,0 +1,47 @@
+package org.koitharu.kotatsu.core.exceptions.resolve
+
+import android.view.View
+import androidx.core.util.Consumer
+import androidx.fragment.app.Fragment
+import com.google.android.material.snackbar.Snackbar
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.ui.ErrorDetailsDialog
+import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
+import org.koitharu.kotatsu.parsers.exception.ParseException
+import org.koitharu.kotatsu.utils.ext.getDisplayMessage
+
+class SnackbarErrorObserver(
+ host: View,
+ fragment: Fragment?,
+ resolver: ExceptionResolver?,
+ onResolved: Consumer?,
+) : ErrorObserver(host, fragment, resolver, onResolved) {
+
+ constructor(
+ host: View,
+ fragment: Fragment?,
+ ) : this(host, fragment, null, null)
+
+ override fun onChanged(value: Throwable?) {
+ if (value == null) {
+ return
+ }
+ val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
+ if (activity is BottomNavOwner) {
+ snackbar.anchorView = activity.bottomNav
+ }
+ if (canResolve(value)) {
+ snackbar.setAction(ExceptionResolver.getResolveStringId(value)) {
+ resolve(value)
+ }
+ } else if (value is ParseException) {
+ val fm = fragmentManager
+ if (fm != null) {
+ snackbar.setAction(R.string.details) {
+ ErrorDetailsDialog.show(fm, value, value.url)
+ }
+ }
+ }
+ snackbar.show()
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt
index b7608a9c8..943e08f2e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt
@@ -14,6 +14,6 @@ object CommonHeaders {
const val ACCEPT_ENCODING = "Accept-Encoding"
const val AUTHORIZATION = "Authorization"
- val CACHE_CONTROL_DISABLED: CacheControl
+ val CACHE_CONTROL_NO_STORE: CacheControl
get() = CacheControl.Builder().noStore().build()
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/MirrorSwitchInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/MirrorSwitchInterceptor.kt
new file mode 100644
index 000000000..909134479
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/network/MirrorSwitchInterceptor.kt
@@ -0,0 +1,88 @@
+package org.koitharu.kotatsu.core.network
+
+import dagger.Lazy
+import okhttp3.Interceptor
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.internal.canParseAsIpAddress
+import okhttp3.internal.closeQuietly
+import okhttp3.internal.publicsuffix.PublicSuffixDatabase
+import org.koitharu.kotatsu.core.parser.MangaRepository
+import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class MirrorSwitchInterceptor @Inject constructor(
+ private val mangaRepositoryFactoryLazy: Lazy,
+) : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+ return try {
+ val response = chain.proceed(request)
+ if (response.isFailed) {
+ val responseCopy = response.newBuilder().build()
+ response.close()
+ trySwitchMirror(request, chain) ?: responseCopy
+ } else {
+ response
+ }
+ } catch (e: Exception) {
+ trySwitchMirror(request, chain) ?: throw e
+ }
+ }
+
+ private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? {
+ val source = request.tag(MangaSource::class.java) ?: return null
+ val repository = mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository ?: return null
+ val mirrors = repository.getAvailableMirrors()
+ if (mirrors.isEmpty()) {
+ return null
+ }
+ return tryMirrors(repository, mirrors, chain, request)
+ }
+
+ private fun tryMirrors(
+ repository: RemoteMangaRepository,
+ mirrors: List,
+ chain: Interceptor.Chain,
+ request: Request,
+ ): Response? {
+ val url = request.url
+ val currentDomain = url.topPrivateDomain()
+ if (currentDomain !in mirrors) {
+ return null
+ }
+ val urlBuilder = url.newBuilder()
+ for (mirror in mirrors) {
+ if (mirror == currentDomain) {
+ continue
+ }
+ val newHost = hostOf(url.host, mirror) ?: continue
+ val newRequest = request.newBuilder()
+ .url(urlBuilder.host(newHost).build())
+ .build()
+ val response = chain.proceed(newRequest)
+ if (response.isFailed) {
+ response.closeQuietly()
+ } else {
+ repository.domain = mirror
+ return response
+ }
+ }
+ return null
+ }
+
+ private val Response.isFailed: Boolean
+ get() = code in 400..599
+
+ private fun hostOf(host: String, newDomain: String): String? {
+ if (newDomain.canParseAsIpAddress()) {
+ return newDomain
+ }
+ val domain = PublicSuffixDatabase.get().getEffectiveTldPlusOne(host) ?: return null
+ return host.removeSuffix(domain) + newDomain
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt
index 934acc008..b90b10b52 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt
@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.parser
+import androidx.annotation.AnyThread
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext
@@ -42,6 +43,7 @@ interface MangaRepository {
private val cache = EnumMap>(MangaSource::class.java)
+ @AnyThread
fun create(source: MangaSource): MangaRepository {
if (source == MangaSource.LOCAL) {
return localMangaRepository
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaTagHighlighter.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaTagHighlighter.kt
new file mode 100644
index 000000000..427d205b6
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaTagHighlighter.kt
@@ -0,0 +1,26 @@
+package org.koitharu.kotatsu.core.parser
+
+import android.content.Context
+import androidx.annotation.ColorRes
+import dagger.Reusable
+import dagger.hilt.android.qualifiers.ApplicationContext
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.parsers.model.MangaTag
+import javax.inject.Inject
+
+@Reusable
+class MangaTagHighlighter @Inject constructor(
+ @ApplicationContext context: Context,
+) {
+
+ private val dict = context.resources.getStringArray(R.array.genres_warnlist).toSet()
+
+ @ColorRes
+ fun getTint(tag: MangaTag): Int {
+ return if (tag.title.lowercase() in dict) {
+ R.color.warning
+ } else {
+ 0
+ }
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt
index 5ef223c52..a99cd9acc 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt
@@ -45,8 +45,11 @@ class RemoteMangaRepository(
getConfig().defaultSortOrder = value
}
- val domain: String
+ var domain: String
get() = parser.domain
+ set(value) {
+ getConfig()[parser.configKeyDomain] = value
+ }
val headers: Headers?
get() = parser.headers
@@ -97,6 +100,10 @@ class RemoteMangaRepository(
parser.onCreateConfig(it)
}
+ fun getAvailableMirrors(): List {
+ return parser.configKeyDomain.presetValues?.toList().orEmpty()
+ }
+
private fun getConfig() = parser.config as SourceSettings
private suspend fun asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred {
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
index 2964b75ff..6386dff7c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
@@ -4,6 +4,7 @@ import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import android.provider.Settings
+import androidx.annotation.FloatRange
import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.arraySetOf
import androidx.core.content.edit
@@ -265,6 +266,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isWebtoonZoomEnable: Boolean
get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true)
+ @get:FloatRange(from = 0.0, to = 1.0)
+ var readerAutoscrollSpeed: Float
+ get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f)
+ set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit { putFloat(KEY_READER_AUTOSCROLL_SPEED, value) }
+
fun isPagesPreloadEnabled(): Boolean {
val policy = NetworkPolicy.from(prefs.getString(KEY_PAGES_PRELOAD, null), NetworkPolicy.NON_METERED)
return policy.isNetworkAllowed(connectivityManager)
@@ -296,6 +302,18 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return list
}
+ fun isTipEnabled(tip: String): Boolean {
+ return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
+ }
+
+ fun closeTip(tip: String) {
+ val closedTips = prefs.getStringSet(KEY_TIPS_CLOSED, emptySet()).orEmpty()
+ if (tip in closedTips) {
+ return
+ }
+ prefs.edit { putStringSet(KEY_TIPS_CLOSED, closedTips + tip) }
+ }
+
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener)
}
@@ -383,7 +401,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LOGS_SHARE = "logs_share"
const val KEY_SOURCES_GRID = "sources_grid"
const val KEY_UPDATES_UNSTABLE = "updates_unstable"
+ const val KEY_TIPS_CLOSED = "tips_closed"
const val KEY_SSL_BYPASS = "ssl_bypass"
+ const val KEY_READER_AUTOSCROLL_SPEED = "as_speed"
// About
const val KEY_APP_UPDATE = "app_update"
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/SourceSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/SourceSettings.kt
index 5f74b3b2d..1b3af7980 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/SourceSettings.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/SourceSettings.kt
@@ -28,4 +28,12 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
} as T
}
+
+ operator fun set(key: ConfigKey, value: T) = prefs.edit {
+ when (key) {
+ is ConfigKey.Domain -> putString(key.key, value as String?)
+ is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
+ is ConfigKey.UserAgent -> putString(key.key, value as String?)
+ }
+ }
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/MangaErrorDialog.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/ErrorDetailsDialog.kt
similarity index 51%
rename from app/src/main/java/org/koitharu/kotatsu/core/ui/MangaErrorDialog.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/ui/ErrorDetailsDialog.kt
index 4930ac0fe..a9bb5eb8a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/ui/MangaErrorDialog.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/ErrorDetailsDialog.kt
@@ -1,5 +1,8 @@
package org.koitharu.kotatsu.core.ui
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
@@ -12,28 +15,24 @@ import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
-import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
-import org.koitharu.kotatsu.databinding.DialogMangaErrorBinding
-import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.databinding.DialogErrorDetailsBinding
+import org.koitharu.kotatsu.utils.ext.isReportable
import org.koitharu.kotatsu.utils.ext.report
-import org.koitharu.kotatsu.utils.ext.requireParcelable
import org.koitharu.kotatsu.utils.ext.requireSerializable
import org.koitharu.kotatsu.utils.ext.withArgs
-class MangaErrorDialog : AlertDialogFragment() {
+class ErrorDetailsDialog : AlertDialogFragment() {
- private lateinit var error: Throwable
- private lateinit var manga: Manga
+ private lateinit var exception: Throwable
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val args = requireArguments()
- manga = args.requireParcelable(ARG_MANGA).manga
- error = args.requireSerializable(ARG_ERROR)
+ exception = args.requireSerializable(ARG_ERROR)
}
- override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): DialogMangaErrorBinding {
- return DialogMangaErrorBinding.inflate(inflater, container, false)
+ override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): DialogErrorDetailsBinding {
+ return DialogErrorDetailsBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -42,31 +41,47 @@ class MangaErrorDialog : AlertDialogFragment() {
movementMethod = LinkMovementMethod.getInstance()
text = context.getString(
R.string.manga_error_description_pattern,
- this@MangaErrorDialog.error.message?.htmlEncode().orEmpty(),
- manga.publicUrl,
+ exception.message?.htmlEncode().orEmpty(),
+ arguments?.getString(ARG_URL),
).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY)
}
}
+ @Suppress("NAME_SHADOWING")
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
- return super.onBuildDialog(builder)
+ val builder = super.onBuildDialog(builder)
.setCancelable(true)
.setNegativeButton(android.R.string.cancel, null)
- .setPositiveButton(R.string.report) { _, _ ->
+ .setTitle(R.string.error_occurred)
+ .setNeutralButton(androidx.preference.R.string.copy) { _, _ ->
+ copyToClipboard()
+ }
+ if (exception.isReportable()) {
+ builder.setPositiveButton(R.string.report) { _, _ ->
dismiss()
- error.report()
- }.setTitle(R.string.error_occurred)
+ exception.report()
+ }
+ }
+ return builder
+ }
+
+ private fun copyToClipboard() {
+ val clipboardManager = context?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
+ ?: return
+ clipboardManager.setPrimaryClip(
+ ClipData.newPlainText(getString(R.string.error), exception.stackTraceToString()),
+ )
}
companion object {
- private const val TAG = "MangaErrorDialog"
+ private const val TAG = "ErrorDetailsDialog"
private const val ARG_ERROR = "error"
- private const val ARG_MANGA = "manga"
+ private const val ARG_URL = "url"
- fun show(fm: FragmentManager, manga: Manga, error: Throwable) = MangaErrorDialog().withArgs(2) {
- putParcelable(ARG_MANGA, ParcelableManga(manga, false))
+ fun show(fm: FragmentManager, error: Throwable, url: String?) = ErrorDetailsDialog().withArgs(2) {
putSerializable(ARG_ERROR, error)
+ putString(ARG_URL, url)
}.show(fm, TAG)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
index 9e4cf2886..1250b1df0 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
@@ -13,6 +13,7 @@ import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.Toast
+import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
import androidx.core.view.isGone
@@ -29,10 +30,9 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar
-import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
+import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
-import org.koitharu.kotatsu.core.ui.MangaErrorDialog
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
@@ -43,9 +43,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.utils.ViewBadge
-import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
-import org.koitharu.kotatsu.utils.ext.isReportable
import org.koitharu.kotatsu.utils.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.utils.ext.textAndVisible
import javax.inject.Inject
@@ -60,17 +58,12 @@ class DetailsActivity :
override val bsHeader: BottomSheetHeaderBar?
get() = binding.headerChapters
- @Inject
- lateinit var viewModelFactory: DetailsViewModel.Factory
-
@Inject
lateinit var shortcutsUpdater: ShortcutsUpdater
private lateinit var viewBadge: ViewBadge
- private val viewModel: DetailsViewModel by assistedViewModels {
- viewModelFactory.create(MangaIntent(intent))
- }
+ private val viewModel: DetailsViewModel by viewModels()
private lateinit var chaptersMenuProvider: ChaptersMenuProvider
private val downloadReceiver = object : BroadcastReceiver() {
@@ -105,7 +98,19 @@ class DetailsActivity :
viewModel.manga.observe(this, ::onMangaUpdated)
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
- viewModel.onError.observe(this, ::onError)
+ viewModel.onError.observe(
+ this,
+ SnackbarErrorObserver(
+ host = binding.containerDetails,
+ fragment = null,
+ resolver = exceptionResolver,
+ onResolved = { isResolved ->
+ if (isResolved) {
+ viewModel.reload()
+ }
+ },
+ ),
+ )
viewModel.onShowToast.observe(this) {
makeSnackbar(getString(it), Snackbar.LENGTH_SHORT).show()
}
@@ -191,37 +196,6 @@ class DetailsActivity :
finishAfterTransition()
}
- private fun onError(e: Throwable) {
- val manga = viewModel.manga.value
- when {
- ExceptionResolver.canResolve(e) -> {
- resolveError(e)
- }
-
- manga == null -> {
- Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
- finishAfterTransition()
- }
-
- else -> {
- val snackbar = makeSnackbar(
- e.getDisplayMessage(resources),
- if (viewModel.manga.value?.chapters == null) {
- Snackbar.LENGTH_INDEFINITE
- } else {
- Snackbar.LENGTH_LONG
- },
- )
- if (e.isReportable()) {
- snackbar.setAction(R.string.details) {
- MangaErrorDialog.show(supportFragmentManager, manga, e)
- }
- }
- snackbar.show()
- }
- }
- }
-
override fun onWindowInsetsChanged(insets: Insets) {
binding.root.updatePadding(
left = insets.left,
@@ -331,17 +305,17 @@ class DetailsActivity :
private class PrefetchObserver(
private val context: Context,
- ) : Observer> {
+ ) : Observer?> {
private var isCalled = false
- override fun onChanged(t: List?) {
- if (t.isNullOrEmpty()) {
+ override fun onChanged(value: List?) {
+ if (value.isNullOrEmpty()) {
return
}
if (!isCalled) {
isCalled = true
- val item = t.find { it.hasFlag(ChapterListItem.FLAG_CURRENT) } ?: t.first()
+ val item = value.find { it.hasFlag(ChapterListItem.FLAG_CURRENT) } ?: value.first()
MangaPrefetchService.prefetchPages(context, item.chapter)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt
index 237f5781c..724bc8250 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt
@@ -5,6 +5,7 @@ import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
@@ -26,6 +27,7 @@ import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
+import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
@@ -39,7 +41,6 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity
-import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
@@ -62,13 +63,15 @@ import javax.inject.Inject
class DetailsFragment :
BaseFragment(),
View.OnClickListener,
- View.OnLongClickListener,
ChipsView.OnChipClickListener,
OnListItemClickListener {
@Inject
lateinit var coil: ImageLoader
+ @Inject
+ lateinit var tagHighlighter: MangaTagHighlighter
+
private val viewModel by activityViewModels()
override fun onInflateView(
@@ -97,6 +100,7 @@ class DetailsFragment :
ReaderActivity.newIntent(view.context, item),
scaleUpActivityOptionsOf(view).toBundle(),
)
+ Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
@@ -264,43 +268,6 @@ class DetailsFragment :
}
}
- override fun onLongClick(v: View): Boolean {
- when (v.id) {
- R.id.button_read -> {
- if (viewModel.historyInfo.value?.history == null) {
- return false
- }
- val menu = PopupMenu(v.context, v)
- menu.inflate(R.menu.popup_read)
- menu.setOnMenuItemClickListener { menuItem ->
- when (menuItem.itemId) {
- R.id.action_read -> {
- val branch = viewModel.selectedBranchValue
- startActivity(
- ReaderActivity.newIntent(
- context = context ?: return@setOnMenuItemClickListener false,
- manga = viewModel.manga.value ?: return@setOnMenuItemClickListener false,
- state = viewModel.chapters.value?.firstOrNull { c ->
- c.chapter.branch == branch
- }?.let { c ->
- ReaderState(c.chapter.id, 0, 0)
- },
- ),
- )
- true
- }
-
- else -> false
- }
- }
- menu.show()
- return true
- }
-
- else -> return false
- }
- }
-
override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag ?: return
startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag)))
@@ -321,7 +288,7 @@ class DetailsFragment :
manga.tags.map { tag ->
ChipsView.ChipModel(
title = tag.title,
- icon = 0,
+ tint = tagHighlighter.getTint(tag),
data = tag,
isCheckable = false,
isChecked = false,
@@ -341,7 +308,7 @@ class DetailsFragment :
.size(CoverSizeResolver(binding.imageViewCover))
.data(imageUrl)
.tag(manga.source)
- .crossfade(context)
+ .crossfade(requireContext())
.lifecycle(viewLifecycleOwner)
.placeholderMemoryCacheKey(manga.coverUrl)
val previousDrawable = lastResult?.drawable
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt
index 888a5f751..a804a3166 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt
@@ -10,9 +10,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -28,12 +26,9 @@ import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.base.domain.MangaDataRepository
-import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
-import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.details.domain.BranchComparator
@@ -51,33 +46,24 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
-import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import java.io.IOException
+import javax.inject.Inject
-class DetailsViewModel @AssistedInject constructor(
- @Assisted intent: MangaIntent,
+@HiltViewModel
+class DetailsViewModel @Inject constructor(
private val historyRepository: HistoryRepository,
favouritesRepository: FavouritesRepository,
private val localMangaRepository: LocalMangaRepository,
trackingRepository: TrackingRepository,
- mangaDataRepository: MangaDataRepository,
private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
private val imageGetter: Html.ImageGetter,
- mangaRepositoryFactory: MangaRepository.Factory,
+ private val delegate: MangaDetailsDelegate,
) : BaseViewModel() {
- private val delegate = MangaDetailsDelegate(
- intent = intent,
- mangaDataRepository = mangaDataRepository,
- historyRepository = historyRepository,
- localMangaRepository = localMangaRepository,
- mangaRepositoryFactory = mangaRepositoryFactory,
- )
-
private var loadingJob: Job
val onShowToast = SingleLiveEvent()
@@ -120,7 +106,7 @@ class DetailsViewModel @AssistedInject constructor(
val bookmarks = delegate.manga.flatMapLatest {
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
+ }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val description = delegate.manga
.distinctUntilChangedBy { it?.description.orEmpty() }
@@ -132,7 +118,7 @@ class DetailsViewModel @AssistedInject constructor(
emit(description.parseAsHtml().filterSpans())
emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans())
}
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null)
+ }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, null)
val onMangaRemoved = SingleLiveEvent()
val isScrobblingAvailable: Boolean
@@ -154,7 +140,7 @@ class DetailsViewModel @AssistedInject constructor(
delegate.selectedBranch,
) { branches, selected ->
branches.indexOf(selected)
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, -1)
+ }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, -1)
val selectedBranchName = delegate.selectedBranch
.asFlowLiveData(viewModelScope.coroutineContext, null)
@@ -164,7 +150,7 @@ class DetailsViewModel @AssistedInject constructor(
isLoading.asFlow(),
) { m, loading ->
m != null && m.chapters.isNullOrEmpty() && !loading
- }.asLiveDataDistinct(viewModelScope.coroutineContext, false)
+ }.asFlowLiveData(viewModelScope.coroutineContext, false)
val chapters = combine(
combine(
@@ -201,7 +187,7 @@ class DetailsViewModel @AssistedInject constructor(
return
}
launchLoadingJob(Dispatchers.Default) {
- val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)
+ val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)?.manga
checkNotNull(manga) { "Cannot find saved manga for ${m.title}" }
val original = localMangaRepository.getRemoteManga(manga)
localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
@@ -279,7 +265,7 @@ class DetailsViewModel @AssistedInject constructor(
fun markChapterAsCurrent(chapterId: Long) {
launchJob(Dispatchers.Default) {
val manga = checkNotNull(delegate.manga.value)
- val chapters = checkNotNull(manga.chapters)
+ val chapters = checkNotNull(manga.getChapters(selectedBranchValue))
val chapterIndex = chapters.indexOfFirst { it.id == chapterId }
check(chapterIndex in chapters.indices) { "Chapter not found" }
val percent = chapterIndex / chapters.size.toFloat()
@@ -321,10 +307,4 @@ class DetailsViewModel @AssistedInject constructor(
}
return scrobbler
}
-
- @AssistedFactory
- interface Factory {
-
- fun create(intent: MangaIntent): DetailsViewModel
- }
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt
index 93a13b52e..b0072f4a8 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt
@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.details.ui
+import androidx.lifecycle.SavedStateHandle
+import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.base.domain.MangaDataRepository
@@ -17,15 +19,17 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
+import javax.inject.Inject
-class MangaDetailsDelegate(
- private val intent: MangaIntent,
+@ViewModelScoped
+class MangaDetailsDelegate @Inject constructor(
+ savedStateHandle: SavedStateHandle,
private val mangaDataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository,
private val localMangaRepository: LocalMangaRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
-
+ private val intent = MangaIntent(savedStateHandle)
private val mangaData = MutableStateFlow(intent.manga)
val selectedBranch = MutableStateFlow(null)
@@ -49,7 +53,7 @@ class MangaDetailsDelegate(
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatchingCancellable null
mangaRepositoryFactory.create(m.source).getDetails(m)
} else {
- localMangaRepository.findSavedManga(manga)
+ localMangaRepository.findSavedManga(manga)?.manga
}
}.onFailure { error ->
error.printStackTraceDebug()
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt
index f5edffbd2..93f32add9 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt
@@ -23,11 +23,10 @@ fun scrobblingInfoAD(
}
bind {
- binding.imageViewCover.newImageRequest(item.coverUrl /* TODO */, null)?.run {
+ binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
- lifecycle(lifecycleOwner)
enqueueWith(coil)
}
binding.textViewTitle.text = item.title
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt
index 802b6e817..dfaae62fd 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt
@@ -123,8 +123,7 @@ class ScrobblingInfoBottomSheet :
binding.spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1)
binding.imageViewLogo.contentDescription = getString(scrobbling.scrobbler.titleResId)
binding.imageViewLogo.setImageResource(scrobbling.scrobbler.iconResId)
- binding.imageViewCover.newImageRequest(scrobbling.coverUrl)?.apply {
- lifecycle(viewLifecycleOwner)
+ binding.imageViewCover.newImageRequest(viewLifecycleOwner, scrobbling.coverUrl)?.apply {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt
index fadfc4167..5f39a198f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt
@@ -1,17 +1,17 @@
package org.koitharu.kotatsu.download.domain
+import android.app.Service
import android.content.Context
import android.webkit.MimeTypeMap
+import androidx.lifecycle.LifecycleService
+import androidx.lifecycle.lifecycleScope
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.android.scopes.ServiceScoped
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
-import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
@@ -30,7 +30,8 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.ui.service.PausingHandle
import org.koitharu.kotatsu.local.data.PagesCache
-import org.koitharu.kotatsu.local.domain.CbzMangaOutput
+import org.koitharu.kotatsu.local.data.input.LocalMangaInput
+import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -41,13 +42,15 @@ import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
import java.io.File
+import javax.inject.Inject
private const val MAX_FAILSAFE_ATTEMPTS = 2
private const val DOWNLOAD_ERROR_DELAY = 500L
private const val SLOWDOWN_DELAY = 200L
-class DownloadManager @AssistedInject constructor(
- @Assisted private val coroutineScope: CoroutineScope,
+@ServiceScoped
+class DownloadManager @Inject constructor(
+ service: Service,
@ApplicationContext private val context: Context,
private val imageLoader: ImageLoader,
private val okHttp: OkHttpClient,
@@ -64,6 +67,7 @@ class DownloadManager @AssistedInject constructor(
androidx.core.R.dimen.compat_notification_large_icon_max_height,
)
private val semaphore = Semaphore(settings.downloadsParallelism)
+ private val coroutineScope = (service as LifecycleService).lifecycleScope
fun downloadManga(
manga: Manga,
@@ -106,7 +110,7 @@ class DownloadManager @AssistedInject constructor(
val destination = localMangaRepository.getOutputDir()
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
val tempFileName = "${manga.id}_$startId.tmp"
- var output: CbzMangaOutput? = null
+ var output: LocalMangaOutput? = null
try {
if (manga.source == MangaSource.LOCAL) {
manga = localMangaRepository.getRemoteManga(manga)
@@ -115,7 +119,7 @@ class DownloadManager @AssistedInject constructor(
val repo = mangaRepositoryFactory.create(manga.source)
outState.value = DownloadState.Preparing(startId, manga, cover)
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
- output = CbzMangaOutput.get(destination, data)
+ output = LocalMangaOutput.getOrCreate(destination, data)
val coverUrl = data.largeCoverUrl ?: data.coverUrl
downloadFile(coverUrl, destination, tempFileName, repo.source).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
@@ -161,11 +165,12 @@ class DownloadManager @AssistedInject constructor(
delay(SLOWDOWN_DELAY)
}
}
+ output.flushChapter(chapter)
}
outState.value = DownloadState.PostProcessing(startId, data, cover)
output.mergeWithExisting()
output.finish()
- val localManga = localMangaRepository.getFromFile(output.file)
+ val localManga = LocalMangaInput.of(output.rootFile).getManga().manga
outState.value = DownloadState.Done(startId, data, cover, localManga)
} catch (e: CancellationException) {
outState.value = DownloadState.Cancelled(startId, manga, cover)
@@ -218,7 +223,7 @@ class DownloadManager @AssistedInject constructor(
val request = Request.Builder()
.url(url)
.tag(MangaSource::class.java, source)
- .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
+ .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
.get()
.build()
val call = okHttp.newCall(request)
@@ -260,10 +265,4 @@ class DownloadManager @AssistedInject constructor(
} finally {
localMangaRepository.unlockManga(manga.id)
}
-
- @AssistedFactory
- interface Factory {
-
- fun create(coroutineScope: CoroutineScope): DownloadManager
- }
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt
index 27feeb00f..2afba0b27 100644
--- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt
@@ -2,9 +2,10 @@ package org.koitharu.kotatsu.download.ui
import android.view.View
import androidx.core.view.isVisible
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
-import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -17,9 +18,10 @@ import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.onFirst
+import org.koitharu.kotatsu.utils.ext.source
fun downloadItemAD(
- scope: CoroutineScope,
+ lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
) = adapterDelegateViewBinding(
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) },
@@ -43,10 +45,11 @@ fun downloadItemAD(
bind {
job?.cancel()
job = item.progressAsFlow().onFirst { state ->
- binding.imageViewCover.newImageRequest(state.manga.coverUrl, state.manga.source)?.run {
+ binding.imageViewCover.newImageRequest(lifecycleOwner, state.manga.coverUrl)?.run {
placeholder(state.cover)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
+ source(state.manga.source)
allowRgb565(true)
enqueueWith(coil)
}
@@ -127,7 +130,7 @@ fun downloadItemAD(
binding.buttonResume.isVisible = false
}
}
- }.launchIn(scope)
+ }.launchIn(lifecycleOwner.lifecycleScope)
}
onViewRecycled {
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt
index 6fb479251..7b0872910 100644
--- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt
@@ -6,7 +6,6 @@ import android.os.Bundle
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
-import androidx.lifecycle.lifecycleScope
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
@@ -27,7 +26,7 @@ class DownloadsActivity : BaseActivity() {
super.onCreate(savedInstanceState)
setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
- val adapter = DownloadsAdapter(lifecycleScope, coil)
+ val adapter = DownloadsAdapter(this, coil)
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
binding.recyclerView.setHasFixedSize(true)
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt
index de75277bf..5962220c3 100644
--- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt
@@ -1,21 +1,21 @@
package org.koitharu.kotatsu.download.ui
+import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
-import kotlinx.coroutines.CoroutineScope
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
typealias DownloadItem = PausingProgressJob
class DownloadsAdapter(
- scope: CoroutineScope,
+ lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
) : AsyncListDifferDelegationAdapter(DiffCallback()) {
init {
- delegatesManager.addDelegate(downloadItemAD(scope, coil))
+ delegatesManager.addDelegate(downloadItemAD(lifecycleOwner, coil))
setHasStableIds(true)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt
index cc0875da5..08f5577bd 100644
--- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt
@@ -44,12 +44,11 @@ import kotlin.collections.set
@AndroidEntryPoint
class DownloadService : BaseService() {
- private lateinit var downloadManager: DownloadManager
private lateinit var downloadNotification: DownloadNotification
private lateinit var wakeLock: PowerManager.WakeLock
@Inject
- lateinit var downloadManagerFactory: DownloadManager.Factory
+ lateinit var downloadManager: DownloadManager
private val jobs = LinkedHashMap>()
private val jobCount = MutableStateFlow(0)
@@ -61,7 +60,6 @@ class DownloadService : BaseService() {
downloadNotification = DownloadNotification(this)
wakeLock = (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
- downloadManager = downloadManagerFactory.create(lifecycleScope)
wakeLock.acquire(TimeUnit.HOURS.toMillis(8))
DownloadNotification.createChannel(this)
startForeground(DownloadNotification.ID_GROUP, downloadNotification.buildGroupNotification())
diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt
index 24a880d0e..7d8e326b8 100644
--- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt
@@ -13,16 +13,15 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
-import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
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.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
-import org.koitharu.kotatsu.base.ui.util.ReversibleAction
+import org.koitharu.kotatsu.base.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.base.ui.util.SpanSizeResolver
import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity
+import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.databinding.FragmentExploreBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.explore.ui.adapter.ExploreAdapter
@@ -30,14 +29,12 @@ import org.koitharu.kotatsu.explore.ui.adapter.ExploreListEventListener
import org.koitharu.kotatsu.explore.ui.model.ExploreItem
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.history.ui.HistoryActivity
-import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
import org.koitharu.kotatsu.utils.ext.addMenuProvider
-import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import javax.inject.Inject
@AndroidEntryPoint
@@ -75,9 +72,9 @@ class ExploreFragment :
viewModel.content.observe(viewLifecycleOwner) {
exploreAdapter?.items = it
}
- viewModel.onError.observe(viewLifecycleOwner, ::onError)
+ viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga)
- viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
+ viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged)
}
@@ -130,32 +127,11 @@ class ExploreFragment :
override fun onEmptyActionClick() = onManageClick(requireView())
- private fun onError(e: Throwable) {
- val snackbar = Snackbar.make(
- binding.recyclerView,
- e.getDisplayMessage(resources),
- Snackbar.LENGTH_SHORT,
- )
- snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav
- snackbar.show()
- }
-
private fun onOpenManga(manga: Manga) {
val intent = DetailsActivity.newIntent(context ?: return, manga)
startActivity(intent)
}
- 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)
- if (handle != null) {
- snackbar.setAction(R.string.undo) { handle.reverseAsync() }
- }
- snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav
- snackbar.show()
- }
-
private fun onGridModeChanged(isGrid: Boolean) {
binding.recyclerView.layoutManager = if (isGrid) {
GridLayoutManager(requireContext(), 4).also { lm ->
diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt
index 19989fb7c..85f32745f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt
@@ -25,7 +25,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
-import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import javax.inject.Inject
@HiltViewModel
@@ -50,7 +49,7 @@ class ExploreViewModel @Inject constructor(
} else {
createContentFlow()
}
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(ExploreItem.Loading))
+ }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(ExploreItem.Loading))
fun openRandom() {
launchLoadingJob(Dispatchers.Default) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt
index 0c9712fcd..a502d6931 100644
--- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt
@@ -21,6 +21,7 @@ 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.source
import org.koitharu.kotatsu.utils.image.FaviconFallbackDrawable
fun exploreButtonsAD(
@@ -76,11 +77,11 @@ fun exploreSourceListItemAD(
bind {
binding.textViewTitle.text = item.source.title
val fallbackIcon = FaviconFallbackDrawable(context, item.source.name)
- binding.imageViewIcon.newImageRequest(item.source.faviconUri(), item.source)?.run {
+ binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
fallback(fallbackIcon)
placeholder(fallbackIcon)
error(fallbackIcon)
- lifecycle(lifecycleOwner)
+ source(item.source)
enqueueWith(coil)
}
}
@@ -107,11 +108,11 @@ fun exploreSourceGridItemAD(
bind {
binding.textViewTitle.text = item.source.title
val fallbackIcon = FaviconFallbackDrawable(context, item.source.name)
- binding.imageViewIcon.newImageRequest(item.source.faviconUri())?.run {
+ binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
fallback(fallbackIcon)
placeholder(fallbackIcon)
error(fallbackIcon)
- lifecycle(lifecycleOwner)
+ source(item.source)
enqueueWith(coil)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt
index 606785b10..d387a46ff 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt
@@ -23,8 +23,8 @@ abstract class FavouriteCategoriesDao {
suspend fun delete(id: Long) = setDeletedAt(id, System.currentTimeMillis())
- @Query("UPDATE favourite_categories SET title = :title, `order` = :order, `track` = :tracker WHERE category_id = :id")
- abstract suspend fun update(id: Long, title: String, order: String, tracker: Boolean)
+ @Query("UPDATE favourite_categories SET title = :title, `order` = :order, `track` = :tracker, `show_in_lib` = :onShelf WHERE category_id = :id")
+ abstract suspend fun update(id: Long, title: String, order: String, tracker: Boolean, onShelf: Boolean)
@Query("UPDATE favourite_categories SET `order` = :order WHERE category_id = :id")
abstract suspend fun updateOrder(id: Long, order: String)
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt
index 1c391de8a..1db0d9b54 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt
@@ -1,12 +1,18 @@
package org.koitharu.kotatsu.favourites.domain
import androidx.room.withTransaction
-import javax.inject.Inject
-import javax.inject.Singleton
-import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.core.db.MangaDatabase
-import org.koitharu.kotatsu.core.db.entity.*
+import org.koitharu.kotatsu.core.db.entity.SortOrder
+import org.koitharu.kotatsu.core.db.entity.toEntities
+import org.koitharu.kotatsu.core.db.entity.toEntity
+import org.koitharu.kotatsu.core.db.entity.toManga
+import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
@@ -15,6 +21,8 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
import org.koitharu.kotatsu.utils.ext.mapItems
+import javax.inject.Inject
+import javax.inject.Singleton
@Singleton
class FavouritesRepository @Inject constructor(
@@ -83,7 +91,12 @@ class FavouritesRepository @Inject constructor(
return db.favouriteCategoriesDao.find(id.toInt()).toFavouriteCategory()
}
- suspend fun createCategory(title: String, sortOrder: SortOrder, isTrackerEnabled: Boolean): FavouriteCategory {
+ suspend fun createCategory(
+ title: String,
+ sortOrder: SortOrder,
+ isTrackerEnabled: Boolean,
+ isVisibleOnShelf: Boolean,
+ ): FavouriteCategory {
val entity = FavouriteCategoryEntity(
title = title,
createdAt = System.currentTimeMillis(),
@@ -92,7 +105,7 @@ class FavouritesRepository @Inject constructor(
order = sortOrder.name,
track = isTrackerEnabled,
deletedAt = 0L,
- isVisibleInLibrary = true,
+ isVisibleInLibrary = isVisibleOnShelf,
)
val id = db.favouriteCategoriesDao.insert(entity)
val category = entity.toFavouriteCategory(id)
@@ -100,8 +113,14 @@ class FavouritesRepository @Inject constructor(
return category
}
- suspend fun updateCategory(id: Long, title: String, sortOrder: SortOrder, isTrackerEnabled: Boolean) {
- db.favouriteCategoriesDao.update(id, title, sortOrder.name, isTrackerEnabled)
+ suspend fun updateCategory(
+ id: Long,
+ title: String,
+ sortOrder: SortOrder,
+ isTrackerEnabled: Boolean,
+ isVisibleOnShelf: Boolean,
+ ) {
+ db.favouriteCategoriesDao.update(id, title, sortOrder.name, isTrackerEnabled, isVisibleOnShelf)
}
suspend fun updateCategory(id: Long, isVisibleInLibrary: Boolean) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt
index ea61ed226..3be5f542f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt
@@ -18,11 +18,11 @@ import androidx.core.view.updatePadding
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
-import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.ListSelectionController
+import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
import org.koitharu.kotatsu.favourites.ui.FavouritesActivity
@@ -31,7 +31,6 @@ import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEdit
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.SortOrder
-import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import javax.inject.Inject
@@ -72,7 +71,7 @@ class FavouriteCategoriesActivity :
onBackPressedDispatcher.addCallback(exitReorderModeCallback)
viewModel.detalizedCategories.observe(this, ::onCategoriesChanged)
- viewModel.onError.observe(this, ::onError)
+ viewModel.onError.observe(this, SnackbarErrorObserver(binding.recyclerView, null))
viewModel.isInReorderMode.observe(this, ::onReorderModeChanged)
}
@@ -146,11 +145,6 @@ class FavouriteCategoriesActivity :
invalidateOptionsMenu()
}
- private fun onError(e: Throwable) {
- Snackbar.make(binding.recyclerView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG)
- .show()
- }
-
private fun onReorderModeChanged(isReorderMode: Boolean) {
val transition = Fade().apply {
duration = resources.getInteger(android.R.integer.config_shortAnimTime).toLong()
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt
index e2d7b277d..6c8d48798 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt
@@ -3,8 +3,6 @@ package org.koitharu.kotatsu.favourites.ui.categories
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
-import java.util.*
-import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -16,9 +14,11 @@ import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState
-import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.mapItems
import org.koitharu.kotatsu.utils.ext.requireValue
+import java.util.Collections
+import javax.inject.Inject
@HiltViewModel
class FavouritesCategoriesViewModel @Inject constructor(
@@ -39,7 +39,7 @@ class FavouritesCategoriesViewModel @Inject constructor(
category = it,
isReorderMode = false,
)
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
+ }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val detalizedCategories = combine(
repository.observeCategoriesWithCovers(),
@@ -62,7 +62,7 @@ class FavouritesCategoriesViewModel @Inject constructor(
),
)
}
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
+ }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
fun deleteCategory(id: Long) {
launchJob {
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt
index f7ba8a348..741faa08a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt
@@ -4,12 +4,12 @@ import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
-import kotlin.jvm.internal.Intrinsics
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
+import kotlin.jvm.internal.Intrinsics
class CategoriesAdapter(
coil: ImageLoader,
@@ -20,7 +20,7 @@ class CategoriesAdapter(
init {
delegatesManager.addDelegate(categoryAD(coil, lifecycleOwner, onItemClickListener))
- .addDelegate(emptyStateListAD(coil, listListener))
+ .addDelegate(emptyStateListAD(coil, lifecycleOwner, listListener))
.addDelegate(loadingStateAD())
}
@@ -31,6 +31,7 @@ class CategoriesAdapter(
oldItem is CategoryListModel && newItem is CategoryListModel -> {
oldItem.category.id == newItem.category.id
}
+
else -> oldItem.javaClass == newItem.javaClass
}
}
@@ -52,6 +53,7 @@ class CategoriesAdapter(
super.getChangePayload(oldItem, newItem)
}
}
+
else -> super.getChangePayload(oldItem, newItem)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt
index 8c633bcfb..d69ea5ec1 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt
@@ -77,13 +77,12 @@ fun categoryAD(
)
}
repeat(coverViews.size) { i ->
- coverViews[i].newImageRequest(item.covers.getOrNull(i))?.run {
+ coverViews[i].newImageRequest(lifecycleOwner, item.covers.getOrNull(i))?.run {
placeholder(R.drawable.ic_placeholder)
fallback(fallback)
crossfade(crossFadeDuration * (i + 1))
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
- lifecycle(lifecycleOwner)
enqueueWith(coil)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt
index 0cdbc597e..8549bd0a7 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt
@@ -4,43 +4,37 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.Editable
-import android.text.TextWatcher
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Filter
+import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
-import com.google.android.material.R as materialR
import dagger.hilt.android.AndroidEntryPoint
-import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
+import org.koitharu.kotatsu.base.ui.util.DefaultTextWatcher
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.databinding.ActivityCategoryEditBinding
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.parsers.model.SortOrder
-import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getSerializableCompat
+import com.google.android.material.R as materialR
@AndroidEntryPoint
class FavouritesCategoryEditActivity :
BaseActivity(),
AdapterView.OnItemClickListener,
View.OnClickListener,
- TextWatcher {
-
- @Inject
- lateinit var viewModelFactory: FavouritesCategoryEditViewModel.Factory
+ DefaultTextWatcher {
- private val viewModel by assistedViewModels {
- viewModelFactory.create(intent.getLongExtra(EXTRA_ID, NO_ID))
- }
+ private val viewModel by viewModels()
private var selectedSortOrder: SortOrder? = null
override fun onCreate(savedInstanceState: Bundle?) {
@@ -83,14 +77,11 @@ class FavouritesCategoryEditActivity :
title = binding.editName.text?.toString()?.trim().orEmpty(),
sortOrder = getSelectedSortOrder(),
isTrackerEnabled = binding.switchTracker.isChecked,
+ isVisibleOnShelf = binding.switchShelf.isChecked,
)
}
}
- override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
-
- override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
-
override fun afterTextChanged(s: Editable?) {
binding.buttonDone.isEnabled = !s.isNullOrBlank()
}
@@ -122,6 +113,9 @@ class FavouritesCategoryEditActivity :
val sortText = getString((category?.order ?: SortOrder.NEWEST).titleRes)
binding.editSort.setText(sortText, false)
binding.switchTracker.isChecked = category?.isTrackingEnabled ?: true
+ binding.switchTracker.jumpDrawablesToCurrentState()
+ binding.switchShelf.isChecked = category?.isVisibleInLibrary ?: true
+ binding.switchShelf.jumpDrawablesToCurrentState()
}
private fun onError(e: Throwable) {
@@ -133,6 +127,7 @@ class FavouritesCategoryEditActivity :
binding.editSort.isEnabled = !isLoading
binding.editName.isEnabled = !isLoading
binding.switchTracker.isEnabled = !isLoading
+ binding.switchShelf.isEnabled = !isLoading
if (isLoading) {
binding.textViewError.isVisible = false
}
@@ -167,9 +162,9 @@ class FavouritesCategoryEditActivity :
companion object {
- private const val EXTRA_ID = "id"
+ const val EXTRA_ID = "id"
+ const val NO_ID = -1L
private const val KEY_SORT_ORDER = "sort"
- private const val NO_ID = -1L
fun newIntent(context: Context, id: Long = NO_ID): Intent {
return Intent(context, FavouritesCategoryEditActivity::class.java)
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt
index 108966280..a322b9d49 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt
@@ -1,27 +1,30 @@
package org.koitharu.kotatsu.favourites.ui.categories.edit
import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
+import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.EXTRA_ID
+import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.NO_ID
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.SingleLiveEvent
+import javax.inject.Inject
-private const val NO_ID = -1L
-
-class FavouritesCategoryEditViewModel @AssistedInject constructor(
- @Assisted private val categoryId: Long,
+@HiltViewModel
+class FavouritesCategoryEditViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
private val repository: FavouritesRepository,
private val settings: AppSettings,
) : BaseViewModel() {
+ private val categoryId = savedStateHandle[EXTRA_ID] ?: NO_ID
+
val onSaved = SingleLiveEvent()
val category = MutableLiveData()
@@ -30,12 +33,14 @@ class FavouritesCategoryEditViewModel @AssistedInject constructor(
}
init {
- launchLoadingJob {
- category.value = if (categoryId != NO_ID) {
- repository.getCategory(categoryId)
- } else {
- null
- }
+ launchLoadingJob(Dispatchers.Default) {
+ category.postValue(
+ if (categoryId != NO_ID) {
+ repository.getCategory(categoryId)
+ } else {
+ null
+ },
+ )
}
}
@@ -43,21 +48,16 @@ class FavouritesCategoryEditViewModel @AssistedInject constructor(
title: String,
sortOrder: SortOrder,
isTrackerEnabled: Boolean,
+ isVisibleOnShelf: Boolean,
) {
- launchLoadingJob {
+ launchLoadingJob(Dispatchers.Default) {
check(title.isNotEmpty())
if (categoryId == NO_ID) {
- repository.createCategory(title, sortOrder, isTrackerEnabled)
+ repository.createCategory(title, sortOrder, isTrackerEnabled, isVisibleOnShelf)
} else {
- repository.updateCategory(categoryId, title, sortOrder, isTrackerEnabled)
+ repository.updateCategory(categoryId, title, sortOrder, isTrackerEnabled, isVisibleOnShelf)
}
- onSaved.call(Unit)
+ onSaved.postCall(Unit)
}
}
-
- @AssistedFactory
- interface Factory {
-
- fun create(categoryId: Long): FavouritesCategoryEditViewModel
- }
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt
index 00941a4be..93bf67e1d 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt
@@ -8,8 +8,8 @@ import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
-import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
@@ -19,7 +19,6 @@ import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEdit
import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
import org.koitharu.kotatsu.parsers.model.Manga
-import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.withArgs
@@ -30,14 +29,7 @@ class FavouriteCategoriesBottomSheet :
View.OnClickListener,
Toolbar.OnMenuItemClickListener {
- @Inject
- lateinit var viewModelFactory: MangaCategoriesViewModel.Factory
-
- private val viewModel: MangaCategoriesViewModel by assistedViewModels {
- viewModelFactory.create(
- requireNotNull(arguments?.getParcelableArrayList(KEY_MANGA_LIST)).map { it.manga },
- )
- }
+ private val viewModel: MangaCategoriesViewModel by viewModels()
private var adapter: MangaCategoriesAdapter? = null
@@ -91,7 +83,7 @@ class FavouriteCategoriesBottomSheet :
companion object {
private const val TAG = "FavouriteCategoriesDialog"
- private const val KEY_MANGA_LIST = "manga_list"
+ const val KEY_MANGA_LIST = "manga_list"
fun show(fm: FragmentManager, manga: Manga) = Companion.show(fm, listOf(manga))
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt
index 4172fa79a..791a79787 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt
@@ -1,23 +1,27 @@
package org.koitharu.kotatsu.favourites.ui.categories.select
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.ids
+import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
+import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet.Companion.KEY_MANGA_LIST
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
-import org.koitharu.kotatsu.parsers.model.Manga
-import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+import org.koitharu.kotatsu.utils.asFlowLiveData
+import javax.inject.Inject
-class MangaCategoriesViewModel @AssistedInject constructor(
- @Assisted private val manga: List,
+@HiltViewModel
+class MangaCategoriesViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
private val favouritesRepository: FavouritesRepository,
) : BaseViewModel() {
+ private val manga = requireNotNull(savedStateHandle.get>(KEY_MANGA_LIST)).map { it.manga }
+
val content = combine(
favouritesRepository.observeCategories(),
observeCategoriesIds(),
@@ -29,7 +33,7 @@ class MangaCategoriesViewModel @AssistedInject constructor(
isChecked = it.id in checked,
)
}
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
+ }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
fun setChecked(categoryId: Long, isChecked: Boolean) {
launchJob(Dispatchers.Default) {
@@ -61,10 +65,4 @@ class MangaCategoriesViewModel @AssistedInject constructor(
result
}
}
-
- @AssistedFactory
- interface Factory {
-
- fun create(manga: List): MangaCategoriesViewModel
- }
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt
index 80366111f..d37f33c56 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt
@@ -6,6 +6,7 @@ import android.view.MenuItem
import android.view.View
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu
+import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.ListSelectionController
@@ -14,20 +15,12 @@ import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.addMenuProvider
-import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.withArgs
-import javax.inject.Inject
@AndroidEntryPoint
class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener {
- @Inject
- lateinit var viewModelFactory: FavouritesListViewModel.Factory
-
- override val viewModel by assistedViewModels { viewModelFactory.create(categoryId) }
-
- private val categoryId: Long
- get() = arguments?.getLong(ARG_CATEGORY_ID) ?: NO_ID
+ override val viewModel by viewModels()
override val isSwipeRefreshEnabled = false
@@ -83,7 +76,7 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
companion object {
const val NO_ID = 0L
- private const val ARG_CATEGORY_ID = "category_id"
+ const val ARG_CATEGORY_ID = "category_id"
fun newInstance(categoryId: Long) = FavouritesListFragment().withArgs(1) {
putLong(ARG_CATEGORY_ID, categoryId)
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt
index a73142563..674701581 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt
@@ -2,10 +2,9 @@ package org.koitharu.kotatsu.favourites.ui.list
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
@@ -13,8 +12,10 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
+import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
+import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
@@ -27,17 +28,21 @@ import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.asFlowLiveData
-import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
+import javax.inject.Inject
-class FavouritesListViewModel @AssistedInject constructor(
- @Assisted val categoryId: Long,
+@HiltViewModel
+class FavouritesListViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
private val repository: FavouritesRepository,
private val trackingRepository: TrackingRepository,
private val historyRepository: HistoryRepository,
private val settings: AppSettings,
+ private val tagHighlighter: MangaTagHighlighter,
) : MangaListViewModel(settings), ListExtraProvider {
+ val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
+
var categoryName: String? = null
private set
@@ -46,7 +51,7 @@ class FavouritesListViewModel @AssistedInject constructor(
} else {
repository.observeCategory(categoryId)
.map { it?.order }
- .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null)
+ .asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, null)
}
override val content = combine(
@@ -71,7 +76,7 @@ class FavouritesListViewModel @AssistedInject constructor(
),
)
- else -> list.toUi(mode, this)
+ else -> list.toUi(mode, this, tagHighlighter)
}
}.catch {
emit(listOf(it.toErrorState(canRetry = false)))
@@ -131,10 +136,4 @@ class FavouritesListViewModel @AssistedInject constructor(
PROGRESS_NONE
}
}
-
- @AssistedFactory
- interface Factory {
-
- fun create(categoryId: Long): FavouritesListViewModel
- }
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt
index 66af005bd..34df167b6 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt
@@ -27,9 +27,11 @@ import org.koitharu.kotatsu.scrobbling.common.domain.tryScrobble
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.mapItems
import javax.inject.Inject
+import javax.inject.Singleton
const val PROGRESS_NONE = -1f
+@Singleton
class HistoryRepository @Inject constructor(
private val db: MangaDatabase,
private val trackingRepository: TrackingRepository,
@@ -37,7 +39,7 @@ class HistoryRepository @Inject constructor(
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
) {
- suspend fun getList(offset: Int, limit: Int = 20): List {
+ suspend fun getList(offset: Int, limit: Int): List {
val entities = db.historyDao.findAll(offset, limit)
return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
}
@@ -135,7 +137,7 @@ class HistoryRepository @Inject constructor(
/**
* Try to replace one manga with another one
- * Useful for replacing saved manga on deleting it with remove source
+ * Useful for replacing saved manga on deleting it with remote source
*/
suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) {
if (alternative == null || db.mangaDao.update(alternative.toEntity()) <= 0) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt
index 8806ea2d2..8f38721f1 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
+import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow
@@ -38,6 +39,7 @@ class HistoryListViewModel @Inject constructor(
private val repository: HistoryRepository,
private val settings: AppSettings,
private val trackingRepository: TrackingRepository,
+ private val tagHighlighter: MangaTagHighlighter,
) : MangaListViewModel(settings) {
val isGroupingEnabled = MutableLiveData()
@@ -118,7 +120,7 @@ class HistoryListViewModel @Inject constructor(
val percent = if (showPercent) history.percent else PROGRESS_NONE
result += when (mode) {
ListMode.LIST -> manga.toListModel(counter, percent)
- ListMode.DETAILED_LIST -> manga.toListDetailedModel(counter, percent)
+ ListMode.DETAILED_LIST -> manga.toListDetailedModel(counter, percent, tagHighlighter)
ListMode.GRID -> manga.toGridModel(counter, percent)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt b/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt
index 0fe13a2d3..9794039e8 100644
--- a/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt
@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityImageBinding
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.enqueueWith
+import org.koitharu.kotatsu.utils.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.utils.ext.indicator
import javax.inject.Inject
@@ -57,7 +58,7 @@ class ImageActivity : BaseActivity() {
.data(url)
.memoryCachePolicy(CachePolicy.DISABLED)
.lifecycle(this)
- .tag(intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource)
+ .tag(intent.getSerializableExtraCompat(EXTRA_SOURCE))
.target(SsivTarget(binding.ssiv))
.indicator(binding.progressBar)
.enqueueWith(coil)
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
index 7527a2c44..cab87b11a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
@@ -31,9 +31,8 @@ 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.fastscroll.FastScroller
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
-import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
-import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
+import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
@@ -55,7 +54,6 @@ import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.clearItemDecorations
-import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getThemeColor
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.resolveDp
@@ -128,7 +126,7 @@ abstract class MangaListFragment :
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged)
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
- viewModel.onError.observe(viewLifecycleOwner, ::onError)
+ viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
}
@@ -175,18 +173,6 @@ abstract class MangaListFragment :
listAdapter?.setItems(list, listCommitCallback)
}
- private fun onError(e: Throwable) {
- if (e is CloudFlareProtectedException) {
- CloudFlareDialog.newInstance(e.url, e.headers).show(childFragmentManager, CloudFlareDialog.TAG)
- } else {
- Snackbar.make(
- binding.recyclerView,
- e.getDisplayMessage(resources),
- Snackbar.LENGTH_SHORT,
- ).show()
- }
- }
-
private fun onActionDone(action: ReversibleAction) {
val handle = action.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt
index 23da29efe..29a4b1dbb 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt
@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.list.ui.adapter
+import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding
@@ -12,6 +13,7 @@ import org.koitharu.kotatsu.utils.ext.setTextAndVisible
fun emptyStateListAD(
coil: ImageLoader,
+ lifecycleOwner: LifecycleOwner,
listener: ListStateHolderListener?,
) = adapterDelegateViewBinding(
{ inflater, parent -> ItemEmptyStateBinding.inflate(inflater, parent, false) },
@@ -22,7 +24,7 @@ fun emptyStateListAD(
}
bind {
- binding.icon.newImageRequest(item.icon)?.enqueueWith(coil)
+ binding.icon.newImageRequest(lifecycleOwner, item.icon)?.enqueueWith(coil)
binding.textPrimary.setText(item.textPrimary)
binding.textSecondary.setTextAndVisible(item.textSecondary)
if (listener != null) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeader2AD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeader2AD.kt
index ba37506f5..d8ab7ce94 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeader2AD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeader2AD.kt
@@ -6,12 +6,13 @@ import org.koitharu.kotatsu.databinding.ItemHeader2Binding
import org.koitharu.kotatsu.list.ui.model.ListHeader2
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaTag
+import org.koitharu.kotatsu.utils.ext.isAnimationsEnabled
import org.koitharu.kotatsu.utils.ext.setTextAndVisible
fun listHeader2AD(
listener: MangaListListener,
) = adapterDelegateViewBinding(
- { layoutInflater, parent -> ItemHeader2Binding.inflate(layoutInflater, parent, false) }
+ { layoutInflater, parent -> ItemHeader2Binding.inflate(layoutInflater, parent, false) },
) {
var ignoreChecking = false
@@ -26,11 +27,15 @@ fun listHeader2AD(
bind { payloads ->
if (payloads.isNotEmpty()) {
- binding.scrollView.smoothScrollTo(0, 0)
+ if (context.isAnimationsEnabled) {
+ binding.scrollView.smoothScrollTo(0, 0)
+ } else {
+ binding.scrollView.scrollTo(0, 0)
+ }
}
ignoreChecking = true
binding.chipsTags.setChips(item.chips) // TODO use recyclerview
ignoreChecking = false
binding.textViewFilter.setTextAndVisible(item.sortOrder?.titleRes ?: 0)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt
index dd63900c0..98859c239 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt
@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
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.source
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
fun mangaGridItemAD(
@@ -38,13 +39,13 @@ fun mangaGridItemAD(
bind { payloads ->
binding.textViewTitle.text = item.title
binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
- binding.imageViewCover.newImageRequest(item.coverUrl, item.source)?.run {
+ binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run {
size(CoverSizeResolver(binding.imageViewCover))
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
- lifecycle(lifecycleOwner)
+ source(item.source)
enqueueWith(coil)
}
badge = itemView.bindBadge(badge, item.counter)
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt
index ca86b5050..1be69ef00 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt
@@ -4,9 +4,15 @@ import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
-import kotlin.jvm.internal.Intrinsics
import org.koitharu.kotatsu.core.ui.DateTimeAgo
-import org.koitharu.kotatsu.list.ui.model.*
+import org.koitharu.kotatsu.list.ui.model.ListHeader
+import org.koitharu.kotatsu.list.ui.model.ListHeader2
+import org.koitharu.kotatsu.list.ui.model.ListModel
+import org.koitharu.kotatsu.list.ui.model.MangaGridModel
+import org.koitharu.kotatsu.list.ui.model.MangaItemModel
+import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel
+import org.koitharu.kotatsu.list.ui.model.MangaListModel
+import kotlin.jvm.internal.Intrinsics
open class MangaListAdapter(
coil: ImageLoader,
@@ -24,7 +30,7 @@ open class MangaListAdapter(
.addDelegate(ITEM_TYPE_DATE, relatedDateItemAD())
.addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(listener))
.addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(listener))
- .addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD(coil, listener))
+ .addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listener))
.addDelegate(ITEM_TYPE_HEADER, listHeaderAD(listener))
.addDelegate(ITEM_TYPE_HEADER_2, listHeader2AD(listener))
}
@@ -35,20 +41,25 @@ open class MangaListAdapter(
oldItem is MangaListModel && newItem is MangaListModel -> {
oldItem.id == newItem.id
}
+
oldItem is MangaListDetailedModel && newItem is MangaListDetailedModel -> {
oldItem.id == newItem.id
}
+
oldItem is MangaGridModel && newItem is MangaGridModel -> {
oldItem.id == newItem.id
}
+
oldItem is DateTimeAgo && newItem is DateTimeAgo -> {
oldItem == newItem
}
+
oldItem is ListHeader && newItem is ListHeader -> {
oldItem.textRes == newItem.textRes &&
oldItem.text == newItem.text &&
oldItem.dateTimeAgo == newItem.dateTimeAgo
}
+
else -> oldItem.javaClass == newItem.javaClass
}
@@ -65,6 +76,7 @@ open class MangaListAdapter(
} else {
}
}
+
is ListHeader2 -> Unit
else -> super.getChangePayload(oldItem, newItem)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt
index b1585ca7f..26ced553f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt
@@ -17,6 +17,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
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.source
import org.koitharu.kotatsu.utils.ext.textAndVisible
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
@@ -51,13 +52,13 @@ fun mangaListDetailedItemAD(
binding.textViewTitle.text = item.title
binding.textViewSubtitle.textAndVisible = item.subtitle
binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
- binding.imageViewCover.newImageRequest(item.coverUrl, item.source)?.run {
+ binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run {
size(CoverSizeResolver(binding.imageViewCover))
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
- lifecycle(lifecycleOwner)
+ source(item.source)
enqueueWith(coil)
}
if (payloads.isEmpty()) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt
index d2e5c9530..48904c2c4 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt
@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
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.source
import org.koitharu.kotatsu.utils.ext.textAndVisible
fun mangaListItemAD(
@@ -34,12 +35,12 @@ fun mangaListItemAD(
bind {
binding.textViewTitle.text = item.title
binding.textViewSubtitle.textAndVisible = item.subtitle
- binding.imageViewCover.newImageRequest(item.coverUrl, item.source)?.run {
+ binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
- lifecycle(lifecycleOwner)
+ source(item.source)
enqueueWith(coil)
}
badge = itemView.bindBadge(badge, item.counter)
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt
index 42930502d..96fb630ae 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt
@@ -16,7 +16,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaTag
-import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import java.text.Collator
@@ -37,7 +37,7 @@ class FilterCoordinator(
private var availableTagsDeferred = loadTagsAsync()
val items: LiveData> = getItemsFlow()
- .asLiveDataDistinct(coroutineScope.coroutineContext + Dispatchers.Default, listOf(FilterItem.Loading))
+ .asFlowLiveData(coroutineScope.coroutineContext + Dispatchers.Default, listOf(FilterItem.Loading))
init {
observeState()
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt
index 215b0c9d9..20fba3c37 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt
@@ -4,6 +4,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
+import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider
@@ -13,7 +14,10 @@ import org.koitharu.kotatsu.utils.ext.ifZero
import java.net.SocketTimeoutException
import java.net.UnknownHostException
-fun Manga.toListModel(counter: Int, progress: Float) = MangaListModel(
+fun Manga.toListModel(
+ counter: Int,
+ progress: Float,
+) = MangaListModel(
id = id,
title = title,
subtitle = tags.joinToString(", ") { it.title },
@@ -23,7 +27,11 @@ fun Manga.toListModel(counter: Int, progress: Float) = MangaListModel(
progress = progress,
)
-fun Manga.toListDetailedModel(counter: Int, progress: Float) = MangaListDetailedModel(
+fun Manga.toListDetailedModel(
+ counter: Int,
+ progress: Float,
+ tagHighlighter: MangaTagHighlighter?,
+) = MangaListDetailedModel(
id = id,
title = title,
subtitle = altTitle,
@@ -31,7 +39,15 @@ fun Manga.toListDetailedModel(counter: Int, progress: Float) = MangaListDetailed
manga = this,
counter = counter,
progress = progress,
- tags = tags.map { ChipsView.ChipModel(0, it.title, false, false, it) },
+ tags = tags.map {
+ ChipsView.ChipModel(
+ tint = tagHighlighter?.getTint(it) ?: 0,
+ title = it.title,
+ isCheckable = false,
+ isChecked = false,
+ data = it,
+ )
+ },
)
fun Manga.toGridModel(counter: Int, progress: Float) = MangaGridModel(
@@ -46,18 +62,21 @@ fun Manga.toGridModel(counter: Int, progress: Float) = MangaGridModel(
suspend fun List.toUi(
mode: ListMode,
extraProvider: ListExtraProvider,
-): List = toUi(ArrayList(size), mode, extraProvider)
+ tagHighlighter: MangaTagHighlighter?,
+): List = toUi(ArrayList(size), mode, extraProvider, tagHighlighter)
fun List.toUi(
mode: ListMode,
-): List = toUi(ArrayList(size), mode)
+ tagHighlighter: MangaTagHighlighter?,
+): List = toUi(ArrayList(size), mode, tagHighlighter)
fun > List.toUi(
destination: C,
mode: ListMode,
+ tagHighlighter: MangaTagHighlighter?,
): C = when (mode) {
ListMode.LIST -> mapTo(destination) { it.toListModel(0, PROGRESS_NONE) }
- ListMode.DETAILED_LIST -> mapTo(destination) { it.toListDetailedModel(0, PROGRESS_NONE) }
+ ListMode.DETAILED_LIST -> mapTo(destination) { it.toListDetailedModel(0, PROGRESS_NONE, tagHighlighter) }
ListMode.GRID -> mapTo(destination) { it.toGridModel(0, PROGRESS_NONE) }
}
@@ -65,13 +84,14 @@ suspend fun > List.toUi(
destination: C,
mode: ListMode,
extraProvider: ListExtraProvider,
+ tagHighlighter: MangaTagHighlighter?,
): C = when (mode) {
ListMode.LIST -> mapTo(destination) {
it.toListModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id))
}
ListMode.DETAILED_LIST -> mapTo(destination) {
- it.toListDetailedModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id))
+ it.toListDetailedModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id), tagHighlighter)
}
ListMode.GRID -> mapTo(destination) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt
index 3db7b7038..ecc5791b1 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt
@@ -12,6 +12,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.buffer
import okio.source
+import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import java.util.zip.ZipFile
class CbzFetcher(
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt
index 5fafe31a1..f74d30258 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt
@@ -1,15 +1,20 @@
package org.koitharu.kotatsu.local.data
import java.io.File
+import java.io.FileFilter
import java.io.FilenameFilter
-import java.util.*
+import java.util.Locale
-class CbzFilter : FilenameFilter {
+class CbzFilter : FileFilter, FilenameFilter {
override fun accept(dir: File, name: String): Boolean {
return isFileSupported(name)
}
+ override fun accept(pathname: File?): Boolean {
+ return isFileSupported(pathname?.name ?: return false)
+ }
+
companion object {
fun isFileSupported(name: String): Boolean {
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/FlowFileObserver.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/FlowFileObserver.kt
deleted file mode 100644
index a5bec126f..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/local/data/FlowFileObserver.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package org.koitharu.kotatsu.local.data
-
-import android.os.FileObserver
-import java.io.File
-import kotlinx.coroutines.channels.ProducerScope
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.channels.trySendBlocking
-import kotlinx.coroutines.flow.callbackFlow
-
-@Suppress("DEPRECATION")
-class FlowFileObserver(
- private val producerScope: ProducerScope,
- 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),
- )
- }
-}
-
-fun File.observe() = callbackFlow {
- val observer = FlowFileObserver(this, this@observe)
- observer.startWatching()
- awaitClose { observer.stopWatching() }
-}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/ImageFileFilter.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/ImageFileFilter.kt
new file mode 100644
index 000000000..29b946b0b
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/ImageFileFilter.kt
@@ -0,0 +1,29 @@
+package org.koitharu.kotatsu.local.data
+
+import java.io.File
+import java.io.FileFilter
+import java.io.FilenameFilter
+import java.util.Locale
+import java.util.zip.ZipEntry
+
+class ImageFileFilter : FilenameFilter, FileFilter {
+
+ override fun accept(dir: File, name: String): Boolean {
+ val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
+ return isExtensionValid(ext)
+ }
+
+ override fun accept(pathname: File?): Boolean {
+ val ext = pathname?.extension?.lowercase(Locale.ROOT) ?: return false
+ return isExtensionValid(ext)
+ }
+
+ fun accept(entry: ZipEntry): Boolean {
+ val ext = entry.name.substringAfterLast('.', "").lowercase(Locale.ROOT)
+ return isExtensionValid(ext)
+ }
+
+ private fun isExtensionValid(ext: String): Boolean {
+ return ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "webp"
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalManga.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/LocalManga.kt
similarity index 62%
rename from app/src/main/java/org/koitharu/kotatsu/local/domain/LocalManga.kt
rename to app/src/main/java/org/koitharu/kotatsu/local/data/LocalManga.kt
index 722a70411..a59f041a4 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalManga.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/LocalManga.kt
@@ -1,15 +1,16 @@
-package org.koitharu.kotatsu.local.domain
+package org.koitharu.kotatsu.local.data
-import java.io.File
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
+import java.io.File
class LocalManga(
- val manga: Manga,
val file: File,
+ val manga: Manga,
) {
var createdAt: Long = -1L
+ private set
get() {
if (field == -1L) {
field = file.lastModified()
@@ -17,6 +18,15 @@ class LocalManga(
return field
}
+ fun isMatchesQuery(query: String): Boolean {
+ return manga.title.contains(query, ignoreCase = true) ||
+ manga.altTitle?.contains(query, ignoreCase = true) == true
+ }
+
+ fun containsTags(tags: Set): Boolean {
+ return manga.tags.containsAll(tags)
+ }
+
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
@@ -34,15 +44,8 @@ class LocalManga(
result = 31 * result + file.hashCode()
return result
}
-}
-
-fun Collection.unwrap(): List = map { it.manga }
-fun LocalManga.isMatchesQuery(query: String): Boolean {
- return manga.title.contains(query, ignoreCase = true) ||
- manga.altTitle?.contains(query, ignoreCase = true) == true
-}
-
-fun LocalManga.containsTags(tags: Set): Boolean {
- return manga.tags.containsAll(tags)
+ override fun toString(): String {
+ return "LocalManga(${file.path}: ${manga.title})"
+ }
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt
index 5c138869c..b473aa6bb 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt
@@ -5,9 +5,6 @@ import android.content.Context
import android.os.StatFs
import androidx.annotation.WorkerThread
import dagger.hilt.android.qualifiers.ApplicationContext
-import java.io.File
-import javax.inject.Inject
-import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
@@ -17,9 +14,13 @@ import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.Cache
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.utils.ext.computeSize
import org.koitharu.kotatsu.utils.ext.getStorageName
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
private const val DIR_NAME = "manga"
private const val CACHE_DISK_PERCENTAGE = 0.02
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt
index ee570e826..9ace45a82 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt
@@ -1,15 +1,21 @@
package org.koitharu.kotatsu.local.data
+import androidx.annotation.WorkerThread
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig
-import org.koitharu.kotatsu.parsers.model.*
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.model.MangaState
+import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.utils.AlphanumComparator
+import java.io.File
class MangaIndex(source: String?) {
@@ -126,6 +132,20 @@ class MangaIndex(source: String?) {
json.put("chapters", newJo)
}
+ fun clear() {
+ val keys = json.keys()
+ while (keys.hasNext()) {
+ json.remove(keys.next())
+ }
+ }
+
+ fun setFrom(other: MangaIndex) {
+ clear()
+ other.json.keys().forEach { key ->
+ json.putOpt(key, other.json.opt(key))
+ }
+ }
+
private fun getChapters(json: JSONObject, source: MangaSource): List {
val chapters = ArrayList(json.length())
for (k in json.keys()) {
@@ -151,4 +171,18 @@ class MangaIndex(source: String?) {
} else {
json.toString()
}
+
+ companion object {
+
+ @WorkerThread
+ fun read(file: File): MangaIndex? {
+ if (file.exists() && file.canRead()) {
+ val text = file.readText()
+ if (text.length > 2) {
+ return MangaIndex(text)
+ }
+ }
+ return null
+ }
+ }
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt
new file mode 100644
index 000000000..82a05f6b1
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt
@@ -0,0 +1,103 @@
+package org.koitharu.kotatsu.local.data.importer
+
+import android.content.Context
+import android.net.Uri
+import androidx.documentfile.provider.DocumentFile
+import dagger.Reusable
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.runInterruptible
+import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
+import org.koitharu.kotatsu.local.data.CbzFilter
+import org.koitharu.kotatsu.local.data.LocalManga
+import org.koitharu.kotatsu.local.data.LocalStorageManager
+import org.koitharu.kotatsu.local.data.input.LocalMangaInput
+import org.koitharu.kotatsu.utils.ext.copyToSuspending
+import org.koitharu.kotatsu.utils.ext.resolveName
+import java.io.File
+import java.io.IOException
+import javax.inject.Inject
+
+@Reusable
+class SingleMangaImporter @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val storageManager: LocalStorageManager,
+) {
+
+ private val contentResolver = context.contentResolver
+
+ suspend fun import(uri: Uri, progressState: MutableStateFlow?): LocalManga {
+ return if (isDirectory(uri)) {
+ importDirectory(uri, progressState)
+ } else {
+ importFile(uri, progressState)
+ }
+ }
+
+ private suspend fun importFile(uri: Uri, progressState: MutableStateFlow?): LocalManga {
+ val contentResolver = storageManager.contentResolver
+ val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri")
+ if (!CbzFilter.isFileSupported(name)) {
+ throw UnsupportedFileException("Unsupported file on $uri")
+ }
+ val dest = File(getOutputDir(), name)
+ runInterruptible {
+ contentResolver.openInputStream(uri)
+ }?.use { source ->
+ dest.outputStream().use { output ->
+ source.copyToSuspending(output, progressState = progressState)
+ }
+ } ?: throw IOException("Cannot open input stream: $uri")
+ return LocalMangaInput.of(dest).getManga()
+ }
+
+ private suspend fun importDirectory(uri: Uri, progressState: MutableStateFlow?): LocalManga {
+ val root = requireNotNull(DocumentFile.fromTreeUri(context, uri)) {
+ "Provided uri $uri is not a tree"
+ }
+ val dest = File(getOutputDir(), root.requireName())
+ dest.mkdir()
+ for (docFile in root.listFiles()) {
+ docFile.copyTo(dest)
+ }
+ return LocalMangaInput.of(dest).getManga()
+ }
+
+ /**
+ * TODO: progress
+ */
+ private suspend fun DocumentFile.copyTo(destDir: File) {
+ if (isDirectory) {
+ val subDir = File(destDir, requireName())
+ subDir.mkdir()
+ for (docFile in listFiles()) {
+ docFile.copyTo(subDir)
+ }
+ } else {
+ inputStream().use { input ->
+ File(destDir, requireName()).outputStream().use { output ->
+ input.copyToSuspending(output)
+ }
+ }
+ }
+ }
+
+ private suspend fun getOutputDir(): File {
+ return storageManager.getDefaultWriteableDir() ?: throw IOException("External files dir unavailable")
+ }
+
+ private suspend fun DocumentFile.inputStream() = runInterruptible(Dispatchers.IO) {
+ contentResolver.openInputStream(uri) ?: throw IOException("Cannot open input stream: $uri")
+ }
+
+ private fun DocumentFile.requireName(): String {
+ return name ?: throw IOException("Cannot fetch name from uri: $uri")
+ }
+
+ private fun isDirectory(uri: Uri): Boolean {
+ return runCatching {
+ DocumentFile.fromTreeUri(context, uri)
+ }.isSuccess
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt
new file mode 100644
index 000000000..9dc597eb0
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt
@@ -0,0 +1,144 @@
+package org.koitharu.kotatsu.local.data.input
+
+import androidx.core.net.toFile
+import androidx.core.net.toUri
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runInterruptible
+import org.koitharu.kotatsu.local.data.CbzFilter
+import org.koitharu.kotatsu.local.data.ImageFileFilter
+import org.koitharu.kotatsu.local.data.LocalManga
+import org.koitharu.kotatsu.local.data.MangaIndex
+import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.parsers.model.MangaPage
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.toCamelCase
+import org.koitharu.kotatsu.utils.AlphanumComparator
+import org.koitharu.kotatsu.utils.ext.listFilesRecursive
+import org.koitharu.kotatsu.utils.ext.longHashCode
+import org.koitharu.kotatsu.utils.ext.toListSorted
+import java.io.File
+import java.util.zip.ZipFile
+
+/**
+ * Manga {Folder}
+ * |--- index.json (optional)
+ * |--- Chapter 1.cbz
+ * |--- Page 1.png
+ * :
+ * L--- Page x.png
+ * |--- Chapter 2.cbz
+ * :
+ * L--- Chapter x.cbz
+ */
+class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
+
+ override suspend fun getManga(): LocalManga = runInterruptible(Dispatchers.IO) {
+ val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX))
+ val mangaUri = root.toUri().toString()
+ val chapterFiles = getChaptersFiles()
+ val info = index?.getMangaInfo()
+ val manga = info?.copy2(
+ source = MangaSource.LOCAL,
+ url = mangaUri,
+ coverUrl = fileUri(
+ root,
+ index.getCoverEntry() ?: findFirstImageEntry().orEmpty(),
+ ),
+ chapters = info.chapters?.mapIndexed { i, c ->
+ c.copy(url = chapterFiles[i].toUri().toString(), source = MangaSource.LOCAL)
+ },
+ ) ?: Manga(
+ id = root.absolutePath.longHashCode(),
+ title = root.name.toHumanReadable(),
+ url = mangaUri,
+ publicUrl = mangaUri,
+ source = MangaSource.LOCAL,
+ coverUrl = findFirstImageEntry().orEmpty(),
+ chapters = chapterFiles.mapIndexed { i, f ->
+ MangaChapter(
+ id = "$i${f.name}".longHashCode(),
+ name = f.nameWithoutExtension.toHumanReadable(),
+ number = i + 1,
+ source = MangaSource.LOCAL,
+ uploadDate = f.lastModified(),
+ url = f.toUri().toString(),
+ scanlator = null,
+ branch = null,
+ )
+ },
+ altTitle = null,
+ rating = -1f,
+ isNsfw = false,
+ tags = setOf(),
+ state = null,
+ author = null,
+ largeCoverUrl = null,
+ description = null,
+ )
+ LocalManga(root, manga)
+ }
+
+ override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) {
+ val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX))
+ index?.getMangaInfo()
+ }
+
+ override suspend fun getPages(chapter: MangaChapter): List = runInterruptible(Dispatchers.IO) {
+ val file = chapter.url.toUri().toFile()
+ if (file.isDirectory) {
+ file.listFilesRecursive(ImageFileFilter())
+ .toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
+ .map {
+ val pageUri = it.toUri().toString()
+ MangaPage(
+ id = pageUri.longHashCode(),
+ url = pageUri,
+ preview = null,
+ source = MangaSource.LOCAL,
+ )
+ }
+ } else {
+ ZipFile(file).use { zip ->
+ zip.entries()
+ .asSequence()
+ .filter { x -> !x.isDirectory }
+ .map { it.name }
+ .toListSorted(AlphanumComparator())
+ .map {
+ val pageUri = zipUri(file, it)
+ MangaPage(
+ id = pageUri.longHashCode(),
+ url = pageUri,
+ preview = null,
+ source = MangaSource.LOCAL,
+ )
+ }
+ }
+ }
+ }
+
+ private fun String.toHumanReadable() = replace("_", " ").toCamelCase()
+
+ private fun getChaptersFiles(): List = root.listFilesRecursive(CbzFilter())
+ .toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
+
+ private fun findFirstImageEntry(): String? {
+ val filter = ImageFileFilter()
+ root.listFilesRecursive(filter).firstOrNull()?.let {
+ return it.toUri().toString()
+ }
+ val cbz = root.listFilesRecursive(CbzFilter()).firstOrNull() ?: return null
+ return ZipFile(cbz).use { zip ->
+ val filter = ImageFileFilter()
+ zip.entries().asSequence()
+ .firstOrNull { x -> !x.isDirectory && filter.accept(x) }
+ ?.let { entry -> zipUri(cbz, entry.name) }
+ }
+ }
+
+ private fun fileUri(base: File, name: String): String {
+ return File(base, name).toUri().toString()
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/input/LocalMangaInput.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/input/LocalMangaInput.kt
new file mode 100644
index 000000000..64f076741
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/input/LocalMangaInput.kt
@@ -0,0 +1,75 @@
+package org.koitharu.kotatsu.local.data.input
+
+import android.net.Uri
+import androidx.core.net.toFile
+import org.koitharu.kotatsu.local.data.LocalManga
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.parsers.model.MangaPage
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import java.io.File
+
+sealed class LocalMangaInput(
+ protected val root: File,
+) {
+
+ abstract suspend fun getManga(): LocalManga
+
+ abstract suspend fun getMangaInfo(): Manga?
+
+ abstract suspend fun getPages(chapter: MangaChapter): List
+
+ companion object {
+
+ fun of(manga: Manga): LocalMangaInput = of(Uri.parse(manga.url).toFile())
+
+ fun of(chapter: MangaChapter): LocalMangaInput = of(Uri.parse(chapter.url).toFile())
+
+ fun of(file: File): LocalMangaInput = when {
+ file.isDirectory -> LocalMangaDirInput(file)
+ else -> LocalMangaZipInput(file)
+ }
+
+ @JvmStatic
+ protected fun zipUri(file: File, entryName: String) = "cbz://${file.path}#$entryName"
+
+ @JvmStatic
+ protected fun Manga.copy2(
+ url: String = this.url,
+ coverUrl: String = this.coverUrl,
+ chapters: List? = this.chapters,
+ source: MangaSource = this.source,
+ ) = Manga(
+ id = id,
+ title = title,
+ altTitle = altTitle,
+ url = url,
+ publicUrl = publicUrl,
+ rating = rating,
+ isNsfw = isNsfw,
+ coverUrl = coverUrl,
+ tags = tags,
+ state = state,
+ author = author,
+ largeCoverUrl = largeCoverUrl,
+ description = description,
+ chapters = chapters,
+ source = source,
+ )
+
+ @JvmStatic
+ protected fun MangaChapter.copy(
+ url: String = this.url,
+ source: MangaSource = this.source,
+ ) = MangaChapter(
+ id = id,
+ name = name,
+ number = number,
+ url = url,
+ scanlator = scanlator,
+ uploadDate = uploadDate,
+ branch = branch,
+ source = source,
+ )
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/input/LocalMangaZipInput.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/input/LocalMangaZipInput.kt
new file mode 100644
index 000000000..7c01e50b8
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/input/LocalMangaZipInput.kt
@@ -0,0 +1,151 @@
+package org.koitharu.kotatsu.local.data.input
+
+import android.net.Uri
+import android.webkit.MimeTypeMap
+import androidx.collection.ArraySet
+import androidx.core.net.toFile
+import androidx.core.net.toUri
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runInterruptible
+import org.koitharu.kotatsu.local.data.LocalManga
+import org.koitharu.kotatsu.local.data.MangaIndex
+import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.parsers.model.MangaPage
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.toCamelCase
+import org.koitharu.kotatsu.utils.AlphanumComparator
+import org.koitharu.kotatsu.utils.ext.longHashCode
+import org.koitharu.kotatsu.utils.ext.readText
+import org.koitharu.kotatsu.utils.ext.toListSorted
+import java.io.File
+import java.util.Enumeration
+import java.util.zip.ZipEntry
+import java.util.zip.ZipFile
+
+/**
+ * Manga archive {.cbz or .zip file}
+ * |--- index.json (optional)
+ * |--- Page 1.png
+ * |--- Page 2.png
+ * :
+ * L--- Page x.png
+ */
+class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
+
+ override suspend fun getManga(): LocalManga {
+ val manga = runInterruptible(Dispatchers.IO) {
+ ZipFile(root).use { zip ->
+ val fileUri = root.toUri().toString()
+ val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)
+ val index = entry?.let(zip::readText)?.let(::MangaIndex)
+ val info = index?.getMangaInfo()
+ if (info != null) {
+ return@use info.copy2(
+ source = MangaSource.LOCAL,
+ url = fileUri,
+ coverUrl = zipUri(
+ root,
+ entryName = index.getCoverEntry()
+ ?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
+ ),
+ chapters = info.chapters?.map { c ->
+ c.copy(url = fileUri, source = MangaSource.LOCAL)
+ },
+ )
+ }
+ // fallback
+ val title = root.nameWithoutExtension.replace("_", " ").toCamelCase()
+ val chapters = ArraySet()
+ for (x in zip.entries()) {
+ if (!x.isDirectory) {
+ chapters += x.name.substringBeforeLast(File.separatorChar, "")
+ }
+ }
+ val uriBuilder = root.toUri().buildUpon()
+ Manga(
+ id = root.absolutePath.longHashCode(),
+ title = title,
+ url = fileUri,
+ publicUrl = fileUri,
+ source = MangaSource.LOCAL,
+ coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()),
+ chapters = chapters.sortedWith(AlphanumComparator()).mapIndexed { i, s ->
+ MangaChapter(
+ id = "$i$s".longHashCode(),
+ name = s.ifEmpty { title },
+ number = i + 1,
+ source = MangaSource.LOCAL,
+ uploadDate = 0L,
+ url = uriBuilder.fragment(s).build().toString(),
+ scanlator = null,
+ branch = null,
+ )
+ },
+ altTitle = null,
+ rating = -1f,
+ isNsfw = false,
+ tags = setOf(),
+ state = null,
+ author = null,
+ largeCoverUrl = null,
+ description = null,
+ )
+ }
+ }
+ return LocalManga(root, manga)
+ }
+
+ override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) {
+ ZipFile(root).use { zip ->
+ val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)
+ val index = entry?.let(zip::readText)?.let(::MangaIndex)
+ index?.getMangaInfo()
+ }
+ }
+
+ override suspend fun getPages(chapter: MangaChapter): List {
+ return runInterruptible(Dispatchers.IO) {
+ val uri = Uri.parse(chapter.url)
+ val file = uri.toFile()
+ val zip = ZipFile(file)
+ val index = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)?.let(zip::readText)?.let(::MangaIndex)
+ var entries = zip.entries().asSequence()
+ entries = if (index != null) {
+ val pattern = index.getChapterNamesPattern(chapter)
+ entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
+ } else {
+ val parent = uri.fragment.orEmpty()
+ entries.filter { x ->
+ !x.isDirectory && x.name.substringBeforeLast(
+ File.separatorChar,
+ "",
+ ) == parent
+ }
+ }
+ entries
+ .toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
+ .map { x ->
+ val entryUri = zipUri(file, x.name)
+ MangaPage(
+ id = entryUri.longHashCode(),
+ url = entryUri,
+ preview = null,
+ source = MangaSource.LOCAL,
+ )
+ }
+ }
+ }
+
+ private fun findFirstImageEntry(entries: Enumeration): ZipEntry? {
+ val list = entries.toList()
+ .filterNot { it.isDirectory }
+ .sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
+ val map = MimeTypeMap.getSingleton()
+ return list.firstOrNull {
+ map.getMimeTypeFromExtension(it.name.substringAfterLast('.'))
+ ?.startsWith("image/") == true
+ }
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt
new file mode 100644
index 000000000..a96b9979f
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt
@@ -0,0 +1,118 @@
+package org.koitharu.kotatsu.local.data.output
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runInterruptible
+import org.koitharu.kotatsu.core.zip.ZipOutput
+import org.koitharu.kotatsu.local.data.MangaIndex
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.parsers.util.toFileNameSafe
+import org.koitharu.kotatsu.utils.ext.deleteAwait
+import org.koitharu.kotatsu.utils.ext.takeIfReadable
+import java.io.File
+
+class LocalMangaDirOutput(
+ rootFile: File,
+ manga: Manga,
+) : LocalMangaOutput(rootFile) {
+
+ private val chaptersOutput = HashMap()
+ private val index = MangaIndex(File(rootFile, ENTRY_NAME_INDEX).takeIfReadable()?.readText())
+
+ init {
+ index.setMangaInfo(manga, append = true)
+ }
+
+ override suspend fun mergeWithExisting() = Unit
+
+ override suspend fun addCover(file: File, ext: String) {
+ val name = buildString {
+ append("cover")
+ if (ext.isNotEmpty() && ext.length <= 4) {
+ append('.')
+ append(ext)
+ }
+ }
+ runInterruptible(Dispatchers.IO) {
+ file.copyTo(File(rootFile, name), overwrite = true)
+ }
+ index.setCoverEntry(name)
+ flushIndex()
+ }
+
+ override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
+ val output = chaptersOutput.getOrPut(chapter) {
+ ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP))
+ }
+ val name = buildString {
+ append(FILENAME_PATTERN.format(chapter.branch.hashCode(), chapter.number, pageNumber))
+ if (ext.isNotEmpty() && ext.length <= 4) {
+ append('.')
+ append(ext)
+ }
+ }
+ runInterruptible(Dispatchers.IO) {
+ output.put(name, file)
+ }
+ index.addChapter(chapter)
+ }
+
+ override suspend fun flushChapter(chapter: MangaChapter) {
+ val output = chaptersOutput.remove(chapter) ?: return
+ output.flushAndFinish()
+ flushIndex()
+ }
+
+ override suspend fun finish() {
+ flushIndex()
+ for (output in chaptersOutput.values) {
+ output.flushAndFinish()
+ }
+ chaptersOutput.clear()
+ }
+
+ override suspend fun cleanup() {
+ for (output in chaptersOutput.values) {
+ output.file.deleteAwait()
+ }
+ }
+
+ override fun close() {
+ for (output in chaptersOutput.values) {
+ output.close()
+ }
+ }
+
+ suspend fun deleteChapter(chapterId: Long) {
+ val chapter = checkNotNull(index.getMangaInfo()?.chapters) {
+ "No chapters found"
+ }.first { it.id == chapterId }
+ val chapterDir = File(rootFile, chapterFileName(chapter))
+ chapterDir.deleteAwait()
+ index.removeChapter(chapterId)
+ }
+
+ fun setIndex(newIndex: MangaIndex) {
+ index.setFrom(newIndex)
+ }
+
+ private suspend fun ZipOutput.flushAndFinish() = runInterruptible(Dispatchers.IO) {
+ finish()
+ close()
+ val resFile = File(file.absolutePath.removeSuffix(SUFFIX_TMP))
+ file.renameTo(resFile)
+ }
+
+ private fun chapterFileName(chapter: MangaChapter): String {
+ return "${chapter.number}_${chapter.name.toFileNameSafe()}".take(18) + ".cbz"
+ }
+
+ private suspend fun flushIndex() = runInterruptible(Dispatchers.IO) {
+ File(rootFile, ENTRY_NAME_INDEX).writeText(index.toString())
+ }
+
+ companion object {
+
+ private const val FILENAME_PATTERN = "%08d_%03d%03d"
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt
new file mode 100644
index 000000000..a486c46f0
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt
@@ -0,0 +1,56 @@
+package org.koitharu.kotatsu.local.data.output
+
+import okio.Closeable
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.parsers.util.toFileNameSafe
+import java.io.File
+
+sealed class LocalMangaOutput(
+ val rootFile: File,
+) : Closeable {
+
+ abstract suspend fun mergeWithExisting()
+
+ abstract suspend fun addCover(file: File, ext: String)
+
+ abstract suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String)
+
+ abstract suspend fun flushChapter(chapter: MangaChapter)
+
+ abstract suspend fun finish()
+
+ abstract suspend fun cleanup()
+
+ companion object {
+
+ const val ENTRY_NAME_INDEX = "index.json"
+ const val SUFFIX_TMP = ".tmp"
+
+ fun getOrCreate(root: File, manga: Manga): LocalMangaOutput {
+ return checkNotNull(getImpl(root, manga, onlyIfExists = false))
+ }
+
+ fun get(root: File, manga: Manga): LocalMangaOutput? {
+ return getImpl(root, manga, onlyIfExists = true)
+ }
+
+ private fun getImpl(root: File, manga: Manga, onlyIfExists: Boolean): LocalMangaOutput? {
+ val name = manga.title.toFileNameSafe()
+ val file = File(root, name)
+ return if (file.exists()) {
+ if (file.isDirectory) {
+ LocalMangaDirOutput(file, manga)
+ } else {
+ LocalMangaZipOutput(file, manga)
+ }
+ } else {
+ if (onlyIfExists) {
+ null
+ } else {
+ LocalMangaDirOutput(file, manga)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaUtil.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaUtil.kt
new file mode 100644
index 000000000..ae6f8a834
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaUtil.kt
@@ -0,0 +1,58 @@
+package org.koitharu.kotatsu.local.data.output
+
+import androidx.core.net.toFile
+import androidx.core.net.toUri
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runInterruptible
+import org.koitharu.kotatsu.local.data.MangaIndex
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaSource
+
+class LocalMangaUtil(
+ private val manga: Manga,
+) {
+
+ init {
+ require(manga.source == MangaSource.LOCAL) {
+ "Expected LOCAL source but ${manga.source} found"
+ }
+ }
+
+ suspend fun deleteChapters(ids: Set) {
+ newOutput().use { output ->
+ when (output) {
+ is LocalMangaZipOutput -> runInterruptible(Dispatchers.IO) {
+ LocalMangaZipOutput.filterChapters(output, ids)
+ }
+
+ is LocalMangaDirOutput -> {
+ for (id in ids) {
+ output.deleteChapter(id)
+ }
+ output.finish()
+ }
+ }
+ }
+ }
+
+ suspend fun writeIndex(index: MangaIndex) {
+ newOutput().use { output ->
+ when (output) {
+ is LocalMangaDirOutput -> {
+ TODO()
+ }
+
+ is LocalMangaZipOutput -> TODO()
+ }
+ }
+ }
+
+ private suspend fun newOutput(): LocalMangaOutput = runInterruptible(Dispatchers.IO) {
+ val file = manga.url.toUri().toFile()
+ if (file.isDirectory) {
+ LocalMangaDirOutput(file, manga)
+ } else {
+ LocalMangaZipOutput(file, manga)
+ }
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt
similarity index 74%
rename from app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt
rename to app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt
index c96d58817..18bdcc023 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt
@@ -1,40 +1,38 @@
-package org.koitharu.kotatsu.local.domain
+package org.koitharu.kotatsu.local.data.output
import androidx.annotation.WorkerThread
-import java.io.File
-import java.util.zip.ZipFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
-import okio.Closeable
import org.koitharu.kotatsu.core.zip.ZipOutput
import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
-import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.readText
+import java.io.File
+import java.util.zip.ZipFile
-class CbzMangaOutput(
- val file: File,
+class LocalMangaZipOutput(
+ rootFile: File,
manga: Manga,
-) : Closeable {
+) : LocalMangaOutput(rootFile) {
- private val output = ZipOutput(File(file.path + ".tmp"))
+ private val output = ZipOutput(File(rootFile.path + ".tmp"))
private val index = MangaIndex(null)
init {
index.setMangaInfo(manga, false)
}
- suspend fun mergeWithExisting() {
- if (file.exists()) {
+ override suspend fun mergeWithExisting() {
+ if (rootFile.exists()) {
runInterruptible(Dispatchers.IO) {
- mergeWith(file)
+ mergeWith(rootFile)
}
}
}
- suspend fun addCover(file: File, ext: String) {
+ override suspend fun addCover(file: File, ext: String) {
val name = buildString {
append(FILENAME_PATTERN.format(0, 0, 0))
if (ext.isNotEmpty() && ext.length <= 4) {
@@ -48,7 +46,7 @@ class CbzMangaOutput(
index.setCoverEntry(name)
}
- suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
+ override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
val name = buildString {
append(FILENAME_PATTERN.format(chapter.branch.hashCode(), chapter.number, pageNumber))
if (ext.isNotEmpty() && ext.length <= 4) {
@@ -62,17 +60,19 @@ class CbzMangaOutput(
index.addChapter(chapter)
}
- suspend fun finish() {
+ override suspend fun flushChapter(chapter: MangaChapter) = Unit
+
+ override suspend fun finish() {
runInterruptible(Dispatchers.IO) {
output.put(ENTRY_NAME_INDEX, index.toString())
output.finish()
output.close()
}
- file.deleteAwait()
- output.file.renameTo(file)
+ rootFile.deleteAwait()
+ output.file.renameTo(rootFile)
}
- suspend fun cleanup() {
+ override suspend fun cleanup() {
output.file.deleteAwait()
}
@@ -80,10 +80,6 @@ class CbzMangaOutput(
output.close()
}
- fun sortChaptersByName() {
- index.sortChaptersByName()
- }
-
@WorkerThread
private fun mergeWith(other: File) {
var otherIndex: MangaIndex? = null
@@ -111,17 +107,9 @@ class CbzMangaOutput(
private const val FILENAME_PATTERN = "%08d_%03d%03d"
- const val ENTRY_NAME_INDEX = "index.json"
-
- fun get(root: File, manga: Manga): CbzMangaOutput {
- val name = manga.title.toFileNameSafe() + ".cbz"
- val file = File(root, name)
- return CbzMangaOutput(file, manga)
- }
-
@WorkerThread
- fun filterChapters(subject: CbzMangaOutput, idsToRemove: Set) {
- ZipFile(subject.file).use { zip ->
+ fun filterChapters(subject: LocalMangaZipOutput, idsToRemove: Set) {
+ ZipFile(subject.rootFile).use { zip ->
val index = MangaIndex(zip.readText(zip.getEntry(ENTRY_NAME_INDEX)))
idsToRemove.forEach { id -> index.removeChapter(id) }
val patterns = requireNotNull(index.getMangaInfo()?.chapters).map {
@@ -133,12 +121,15 @@ class CbzMangaOutput(
entry.name == ENTRY_NAME_INDEX -> {
subject.output.put(ENTRY_NAME_INDEX, index.toString())
}
+
entry.isDirectory -> {
subject.output.addDirectory(entry.name)
}
+
entry.name == coverEntryName -> {
subject.output.copyEntryFrom(zip, entry)
}
+
else -> {
val name = entry.name.substringBefore('.')
if (patterns.any { it.matches(name) }) {
@@ -149,8 +140,8 @@ class CbzMangaOutput(
}
subject.output.finish()
subject.output.close()
- subject.file.delete()
- subject.output.file.renameTo(subject.file)
+ subject.rootFile.delete()
+ subject.output.file.renameTo(subject.rootFile)
}
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/ExtraCloseableSource.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/util/ExtraCloseableSource.kt
similarity index 90%
rename from app/src/main/java/org/koitharu/kotatsu/local/data/ExtraCloseableSource.kt
rename to app/src/main/java/org/koitharu/kotatsu/local/data/util/ExtraCloseableSource.kt
index 342c6bac8..b83867e5c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/data/ExtraCloseableSource.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/util/ExtraCloseableSource.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.local.data
+package org.koitharu.kotatsu.local.data.util
import okhttp3.internal.closeQuietly
import okio.Closeable
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/util/FlowFileObserver.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/util/FlowFileObserver.kt
new file mode 100644
index 000000000..c167b87c1
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/util/FlowFileObserver.kt
@@ -0,0 +1,48 @@
+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,
+ 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,
+ 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),
+ )
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt
index d110baac3..c2383b572 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt
@@ -1,14 +1,7 @@
package org.koitharu.kotatsu.local.domain
-import android.annotation.SuppressLint
import android.net.Uri
-import android.webkit.MimeTypeMap
-import androidx.annotation.WorkerThread
-import androidx.collection.ArraySet
import androidx.core.net.toFile
-import androidx.core.net.toUri
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -17,30 +10,25 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.parser.MangaRepository
-import org.koitharu.kotatsu.local.data.CbzFilter
+import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageManager
-import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.local.data.TempFileFilter
+import org.koitharu.kotatsu.local.data.input.LocalMangaInput
+import org.koitharu.kotatsu.local.data.output.LocalMangaUtil
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
-import org.koitharu.kotatsu.parsers.util.toCamelCase
import org.koitharu.kotatsu.utils.AlphanumComparator
import org.koitharu.kotatsu.utils.CompositeMutex
import org.koitharu.kotatsu.utils.ext.deleteAwait
-import org.koitharu.kotatsu.utils.ext.longHashCode
-import org.koitharu.kotatsu.utils.ext.readText
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import java.io.File
-import java.util.Enumeration
-import java.util.zip.ZipEntry
-import java.util.zip.ZipFile
import javax.inject.Inject
import javax.inject.Singleton
-import kotlin.coroutines.CoroutineContext
private const val MAX_PARALLELISM = 4
@@ -48,7 +36,6 @@ private const val MAX_PARALLELISM = 4
class LocalMangaRepository @Inject constructor(private val storageManager: LocalStorageManager) : MangaRepository {
override val source = MangaSource.LOCAL
- private val filenameFilter = CbzFilter()
private val locks = CompositeMutex()
override suspend fun getList(offset: Int, query: String): List {
@@ -82,46 +69,16 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
return list.unwrap()
}
- override suspend fun getDetails(manga: Manga) = when {
- manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)) {
+ override suspend fun getDetails(manga: Manga): Manga = when {
+ manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)?.manga) {
"Manga is not local or saved"
}
- else -> getFromFile(Uri.parse(manga.url).toFile())
+ else -> LocalMangaInput.of(manga).getManga().manga
}
override suspend fun getPages(chapter: MangaChapter): List {
- return runInterruptible(Dispatchers.IO) {
- val uri = Uri.parse(chapter.url)
- val file = uri.toFile()
- val zip = ZipFile(file)
- val index = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX)?.let(zip::readText)?.let(::MangaIndex)
- var entries = zip.entries().asSequence()
- entries = if (index != null) {
- val pattern = index.getChapterNamesPattern(chapter)
- entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
- } else {
- val parent = uri.fragment.orEmpty()
- entries.filter { x ->
- !x.isDirectory && x.name.substringBeforeLast(
- File.separatorChar,
- "",
- ) == parent
- }
- }
- entries
- .toList()
- .sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
- .map { x ->
- val entryUri = zipUri(file, x.name)
- MangaPage(
- id = entryUri.longHashCode(),
- url = entryUri,
- preview = null,
- source = MangaSource.LOCAL,
- )
- }
- }
+ return LocalMangaInput.of(chapter).getPages(chapter)
}
suspend fun delete(manga: Manga): Boolean {
@@ -132,109 +89,30 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
suspend fun deleteChapters(manga: Manga, ids: Set) {
lockManga(manga.id)
try {
- runInterruptible(Dispatchers.IO) {
- val uri = Uri.parse(manga.url)
- val file = uri.toFile()
- val cbz = CbzMangaOutput(file, manga)
- CbzMangaOutput.filterChapters(cbz, ids)
- }
+ LocalMangaUtil(manga).deleteChapters(ids)
} finally {
unlockManga(manga.id)
}
}
- @WorkerThread
- @SuppressLint("DefaultLocale")
- fun getFromFile(file: File): Manga = ZipFile(file).use { zip ->
- val fileUri = file.toUri().toString()
- val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX)
- val index = entry?.let(zip::readText)?.let(::MangaIndex)
- val info = index?.getMangaInfo()
- if (index != null && info != null) {
- return info.copy2(
- source = MangaSource.LOCAL,
- url = fileUri,
- coverUrl = zipUri(
- file,
- entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
- ),
- chapters = info.chapters?.map { c ->
- c.copy(url = fileUri, source = MangaSource.LOCAL)
- },
- )
- }
- // fallback
- val title = file.nameWithoutExtension.replace("_", " ").toCamelCase()
- val chapters = ArraySet()
- for (x in zip.entries()) {
- if (!x.isDirectory) {
- chapters += x.name.substringBeforeLast(File.separatorChar, "")
- }
- }
- val uriBuilder = file.toUri().buildUpon()
- Manga(
- id = file.absolutePath.longHashCode(),
- title = title,
- url = fileUri,
- publicUrl = fileUri,
- source = MangaSource.LOCAL,
- coverUrl = zipUri(file, findFirstImageEntry(zip.entries())?.name.orEmpty()),
- chapters = chapters.sortedWith(AlphanumComparator()).mapIndexed { i, s ->
- MangaChapter(
- id = "$i$s".longHashCode(),
- name = s.ifEmpty { title },
- number = i + 1,
- source = MangaSource.LOCAL,
- uploadDate = 0L,
- url = uriBuilder.fragment(s).build().toString(),
- scanlator = null,
- branch = null,
- )
- },
- altTitle = null,
- rating = -1f,
- isNsfw = false,
- tags = setOf(),
- state = null,
- author = null,
- largeCoverUrl = null,
- description = null,
- )
- }
-
suspend fun getRemoteManga(localManga: Manga): Manga? {
- val file = runCatching {
- Uri.parse(localManga.url).toFile()
- }.getOrNull() ?: return null
- return runInterruptible(Dispatchers.IO) {
- ZipFile(file).use { zip ->
- val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX)
- val index = entry?.let(zip::readText)?.let(::MangaIndex)
- index?.getMangaInfo()
- }
- }
+ return runCatchingCancellable {
+ LocalMangaInput.of(localManga).getMangaInfo()
+ }.onFailure {
+ it.printStackTraceDebug()
+ }.getOrNull()
}
- suspend fun findSavedManga(remoteManga: Manga): Manga? {
+ suspend fun findSavedManga(remoteManga: Manga): LocalManga? {
val files = getAllFiles()
- return runInterruptible(Dispatchers.IO) {
- for (file in files) {
- val index = ZipFile(file).use { zip ->
- val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX)
- entry?.let(zip::readText)?.let(::MangaIndex)
- } ?: continue
- val info = index.getMangaInfo() ?: continue
- if (info.id == remoteManga.id) {
- val fileUri = file.toUri().toString()
- return@runInterruptible info.copy2(
- source = MangaSource.LOCAL,
- url = fileUri,
- chapters = info.chapters?.map { c -> c.copy(url = fileUri) },
- )
- }
+ val input = files.firstNotNullOfOrNull { file ->
+ LocalMangaInput.of(file).takeIf {
+ runCatchingCancellable {
+ it.getMangaInfo()
+ }.getOrNull()?.id == remoteManga.id
}
- null
}
+ return input?.getManga()
}
suspend fun watchReadableDirs(): Flow {
@@ -244,28 +122,6 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
.filterNot { filter.accept(it, it.name) }
}
- private fun CoroutineScope.getFromFileAsync(
- file: File,
- context: CoroutineContext,
- ): Deferred = async(context) {
- runInterruptible {
- runCatchingCancellable { LocalManga(getFromFile(file), file) }.getOrNull()
- }
- }
-
- private fun zipUri(file: File, entryName: String) = "cbz://${file.path}#$entryName"
-
- private fun findFirstImageEntry(entries: Enumeration): ZipEntry? {
- val list = entries.toList()
- .filterNot { it.isDirectory }
- .sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
- val map = MimeTypeMap.getSingleton()
- return list.firstOrNull {
- map.getMimeTypeFromExtension(it.name.substringAfterLast('.'))
- ?.startsWith("image/") == true
- }
- }
-
override val sortOrders = setOf(SortOrder.ALPHABETICAL, SortOrder.RATING)
override suspend fun getPageUrl(page: MangaPage) = page.url
@@ -300,49 +156,16 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
return coroutineScope {
val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
files.map { file ->
- getFromFileAsync(file, dispatcher)
+ async(dispatcher) {
+ runCatchingCancellable { LocalMangaInput.of(file).getManga() }.getOrNull()
+ }
}.awaitAll()
}.filterNotNullTo(ArrayList(files.size))
}
private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir ->
- dir.listFiles(filenameFilter)?.toList().orEmpty()
+ dir.listFiles()?.toList().orEmpty()
}
- private fun Manga.copy2(
- url: String = this.url,
- coverUrl: String = this.coverUrl,
- chapters: List? = this.chapters,
- source: MangaSource = this.source,
- ) = Manga(
- id = id,
- title = title,
- altTitle = altTitle,
- url = url,
- publicUrl = publicUrl,
- rating = rating,
- isNsfw = isNsfw,
- coverUrl = coverUrl,
- tags = tags,
- state = state,
- author = author,
- largeCoverUrl = largeCoverUrl,
- description = description,
- chapters = chapters,
- source = source,
- )
-
- private fun MangaChapter.copy(
- url: String = this.url,
- source: MangaSource = this.source,
- ) = MangaChapter(
- id = id,
- name = name,
- number = number,
- url = url,
- scanlator = scanlator,
- uploadDate = uploadDate,
- branch = branch,
- source = source,
- )
+ private fun Collection.unwrap(): List = map { it.manga }
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt
deleted file mode 100644
index b117f1395..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt
+++ /dev/null
@@ -1,143 +0,0 @@
-package org.koitharu.kotatsu.local.domain.importer
-
-import android.content.Context
-import android.net.Uri
-import android.webkit.MimeTypeMap
-import androidx.documentfile.provider.DocumentFile
-import kotlinx.coroutines.NonCancellable
-import kotlinx.coroutines.withContext
-import org.koitharu.kotatsu.local.data.LocalStorageManager
-import org.koitharu.kotatsu.local.domain.CbzMangaOutput
-import org.koitharu.kotatsu.local.domain.LocalMangaRepository
-import org.koitharu.kotatsu.parsers.model.Manga
-import org.koitharu.kotatsu.parsers.model.MangaChapter
-import org.koitharu.kotatsu.parsers.model.MangaSource
-import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
-import org.koitharu.kotatsu.utils.AlphanumComparator
-import org.koitharu.kotatsu.utils.ext.copyToSuspending
-import org.koitharu.kotatsu.utils.ext.deleteAwait
-import org.koitharu.kotatsu.utils.ext.longOf
-import java.io.File
-
-// TODO: Add support for chapters in cbz
-// https://github.com/KotatsuApp/Kotatsu/issues/31
-class DirMangaImporter(
- private val context: Context,
- storageManager: LocalStorageManager,
- private val localMangaRepository: LocalMangaRepository,
-) : MangaImporter(storageManager) {
-
- private val contentResolver = context.contentResolver
-
- override suspend fun import(uri: Uri): Manga {
- val root = requireNotNull(DocumentFile.fromTreeUri(context, uri)) {
- "Provided uri $uri is not a tree"
- }
- val manga = Manga(root)
- val output = CbzMangaOutput.get(getOutputDir(), manga)
- try {
- val dest = output.use {
- addPages(
- output = it,
- root = root,
- path = "",
- state = State(uri.hashCode(), 0, false),
- )
- it.sortChaptersByName()
- it.mergeWithExisting()
- it.finish()
- it.file
- }
- return localMangaRepository.getFromFile(dest)
- } finally {
- withContext(NonCancellable) {
- output.cleanup()
- File(getOutputDir(), "page.tmp").deleteAwait()
- }
- }
- }
-
- private suspend fun addPages(output: CbzMangaOutput, root: DocumentFile, path: String, state: State) {
- var number = 0
- for (file in root.listFiles().sortedWith(compareBy(AlphanumComparator()) { it.name.orEmpty() })) {
- when {
- file.isDirectory -> {
- addPages(output, file, path + "/" + file.name, state)
- }
-
- file.isFile -> {
- val tempFile = file.asTempFile()
- if (!state.hasCover) {
- output.addCover(tempFile, file.extension)
- state.hasCover = true
- }
- output.addPage(
- chapter = state.getChapter(path),
- file = tempFile,
- pageNumber = number,
- ext = file.extension,
- )
- number++
- }
- }
- }
- }
-
- private suspend fun DocumentFile.asTempFile(): File {
- val file = File(getOutputDir(), "page.tmp")
- checkNotNull(contentResolver.openInputStream(uri)) {
- "Cannot open input stream for $uri"
- }.use { input ->
- file.outputStream().use { output ->
- input.copyToSuspending(output)
- }
- }
- return file
- }
-
- private fun Manga(file: DocumentFile) = Manga(
- id = longOf(file.uri.hashCode(), 0),
- title = checkNotNull(file.name),
- altTitle = null,
- url = file.uri.path.orEmpty(),
- publicUrl = file.uri.toString(),
- rating = RATING_UNKNOWN,
- isNsfw = false,
- coverUrl = "",
- tags = emptySet(),
- state = null,
- author = null,
- source = MangaSource.LOCAL,
- )
-
- private val DocumentFile.extension: String
- get() = type?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
- ?: name?.substringAfterLast('.')?.takeIf { it.length in 2..4 }
- ?: error("Cannot obtain extension of $uri")
-
- private class State(
- private val rootId: Int,
- private var counter: Int,
- var hasCover: Boolean,
- ) {
-
- private val chapters = HashMap()
-
- @Synchronized
- fun getChapter(path: String): MangaChapter {
- return chapters.getOrPut(path) {
- counter++
- MangaChapter(
- id = longOf(rootId, counter),
- name = path.replace('/', ' ').trim(),
- number = counter,
- url = path.ifEmpty { "Default chapter" },
- scanlator = null,
- uploadDate = 0L,
- branch = null,
- source = MangaSource.LOCAL,
- )
- }
- }
- }
-}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/MangaImporter.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/MangaImporter.kt
deleted file mode 100644
index dc281b920..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/MangaImporter.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package org.koitharu.kotatsu.local.domain.importer
-
-import android.content.Context
-import android.net.Uri
-import androidx.documentfile.provider.DocumentFile
-import dagger.hilt.android.qualifiers.ApplicationContext
-import java.io.File
-import java.io.IOException
-import javax.inject.Inject
-import org.koitharu.kotatsu.local.data.LocalStorageManager
-import org.koitharu.kotatsu.local.domain.LocalMangaRepository
-import org.koitharu.kotatsu.parsers.model.Manga
-
-abstract class MangaImporter(
- protected val storageManager: LocalStorageManager,
-) {
-
- abstract suspend fun import(uri: Uri): Manga
-
- suspend fun getOutputDir(): File {
- return storageManager.getDefaultWriteableDir() ?: throw IOException("External files dir unavailable")
- }
-
- class Factory @Inject constructor(
- @ApplicationContext private val context: Context,
- private val storageManager: LocalStorageManager,
- private val localMangaRepository: LocalMangaRepository,
- ) {
-
- fun create(uri: Uri): MangaImporter {
- return when {
- isDir(uri) -> DirMangaImporter(context, storageManager, localMangaRepository)
- else -> ZipMangaImporter(storageManager, localMangaRepository)
- }
- }
-
- private fun isDir(uri: Uri): Boolean {
- return runCatching {
- DocumentFile.fromTreeUri(context, uri)
- }.isSuccess
- }
- }
-}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt
deleted file mode 100644
index fdf24abd1..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-package org.koitharu.kotatsu.local.domain.importer
-
-import android.net.Uri
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.runInterruptible
-import kotlinx.coroutines.withContext
-import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
-import org.koitharu.kotatsu.local.data.CbzFilter
-import org.koitharu.kotatsu.local.data.LocalStorageManager
-import org.koitharu.kotatsu.local.domain.LocalMangaRepository
-import org.koitharu.kotatsu.parsers.model.Manga
-import org.koitharu.kotatsu.utils.ext.copyToSuspending
-import org.koitharu.kotatsu.utils.ext.resolveName
-import java.io.File
-import java.io.IOException
-
-class ZipMangaImporter(
- storageManager: LocalStorageManager,
- private val localMangaRepository: LocalMangaRepository,
-) : MangaImporter(storageManager) {
-
- override suspend fun import(uri: Uri): Manga {
- val contentResolver = storageManager.contentResolver
- return withContext(Dispatchers.IO) {
- val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri")
- if (!CbzFilter.isFileSupported(name)) {
- throw UnsupportedFileException("Unsupported file on $uri")
- }
- val dest = File(getOutputDir(), name)
- runInterruptible {
- contentResolver.openInputStream(uri)
- }?.use { source ->
- dest.outputStream().use { output ->
- source.copyToSuspending(output)
- }
- } ?: throw IOException("Cannot open input stream: $uri")
- localMangaRepository.getFromFile(dest)
- }
- }
-}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt
index 5cd6cf85b..b72f82200 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt
@@ -5,23 +5,27 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.FragmentManager
-import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.databinding.DialogImportBinding
+import org.koitharu.kotatsu.settings.backup.BackupDialogFragment
+import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment
class ImportDialogFragment : AlertDialogFragment(), View.OnClickListener {
- private val viewModel by activityViewModels()
private val importFileCall = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) {
startImport(it)
}
private val importDirCall = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) {
startImport(listOfNotNull(it))
}
+ private val backupSelectCall = registerForActivityResult(ActivityResultContracts.OpenDocument()) {
+ restoreBackup(it)
+ }
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): DialogImportBinding {
return DialogImportBinding.inflate(inflater, container, false)
@@ -38,17 +42,30 @@ class ImportDialogFragment : AlertDialogFragment(), View.On
super.onViewCreated(view, savedInstanceState)
binding.buttonDir.setOnClickListener(this)
binding.buttonFile.setOnClickListener(this)
+ binding.buttonBackup.setOnClickListener(this)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_file -> importFileCall.launch(arrayOf("*/*"))
R.id.button_dir -> importDirCall.launch(null)
+ R.id.button_backup -> backupSelectCall.launch(arrayOf("*/*"))
}
}
private fun startImport(uris: Collection) {
- ImportService.start(requireContext(), uris)
+ if (uris.isEmpty()) {
+ return
+ }
+ val ctx = requireContext()
+ ImportWorker.start(ctx, uris)
+ Toast.makeText(ctx, R.string.import_will_start_soon, Toast.LENGTH_LONG).show()
+ dismiss()
+ }
+
+ private fun restoreBackup(uri: Uri?) {
+ RestoreDialogFragment.newInstance(uri ?: return)
+ .show(parentFragmentManager, BackupDialogFragment.TAG)
dismiss()
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportService.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportService.kt
deleted file mode 100644
index f2efd9d27..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportService.kt
+++ /dev/null
@@ -1,184 +0,0 @@
-package org.koitharu.kotatsu.local.ui
-
-import android.app.NotificationChannel
-import android.app.NotificationManager
-import android.app.PendingIntent
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import android.os.Build
-import android.widget.Toast
-import androidx.core.app.NotificationCompat
-import androidx.core.app.ServiceCompat
-import androidx.core.content.ContextCompat
-import coil.ImageLoader
-import coil.request.ImageRequest
-import dagger.hilt.android.AndroidEntryPoint
-import kotlinx.coroutines.CancellationException
-import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.base.ui.CoroutineIntentService
-import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
-import org.koitharu.kotatsu.details.ui.DetailsActivity
-import org.koitharu.kotatsu.download.ui.service.DownloadService
-import org.koitharu.kotatsu.local.domain.importer.MangaImporter
-import org.koitharu.kotatsu.parsers.model.Manga
-import org.koitharu.kotatsu.utils.PendingIntentCompat
-import org.koitharu.kotatsu.utils.ext.asArrayList
-import org.koitharu.kotatsu.utils.ext.getDisplayMessage
-import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
-import org.koitharu.kotatsu.utils.ext.report
-import org.koitharu.kotatsu.utils.ext.toBitmapOrNull
-import javax.inject.Inject
-
-@AndroidEntryPoint
-class ImportService : CoroutineIntentService() {
-
- @Inject
- lateinit var importerFactory: MangaImporter.Factory
-
- @Inject
- lateinit var coil: ImageLoader
-
- private lateinit var notificationManager: NotificationManager
-
- override fun onCreate() {
- super.onCreate()
- isRunning = true
- notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
- }
-
- override fun onDestroy() {
- isRunning = false
- super.onDestroy()
- }
-
- override suspend fun processIntent(startId: Int, intent: Intent) {
- val uris = intent.getParcelableArrayListExtra(EXTRA_URIS)
- if (uris.isNullOrEmpty()) {
- return
- }
- startForeground()
- for (uri in uris) {
- try {
- val manga = importImpl(uri)
- showNotification(uri, manga, null)
- sendBroadcast(manga)
- } catch (e: CancellationException) {
- throw e
- } catch (e: Throwable) {
- e.printStackTraceDebug()
- showNotification(uri, null, e)
- }
- }
- ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
- }
-
- override fun onError(startId: Int, error: Throwable) {
- error.report()
- }
-
- private suspend fun importImpl(uri: Uri): Manga {
- val importer = importerFactory.create(uri)
- return importer.import(uri)
- }
-
- private fun sendBroadcast(manga: Manga) {
- sendBroadcast(
- Intent(DownloadService.ACTION_DOWNLOAD_COMPLETE)
- .putExtra(DownloadService.EXTRA_MANGA, ParcelableManga(manga, withChapters = false)),
- )
- }
-
- private suspend fun showNotification(uri: Uri, manga: Manga?, error: Throwable?) {
- val notification = NotificationCompat.Builder(this, CHANNEL_ID)
- .setPriority(NotificationCompat.PRIORITY_DEFAULT)
- .setDefaults(0)
- .setColor(ContextCompat.getColor(this, R.color.blue_primary_dark))
- .setSilent(true)
- if (manga != null) {
- notification.setLargeIcon(
- coil.execute(
- ImageRequest.Builder(applicationContext)
- .data(manga.coverUrl)
- .tag(manga.source)
- .build(),
- ).toBitmapOrNull(),
- )
- notification.setSubText(manga.title)
- val intent = DetailsActivity.newIntent(applicationContext, manga)
- notification.setContentIntent(
- PendingIntent.getActivity(
- applicationContext,
- manga.id.toInt(),
- intent,
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE,
- ),
- ).setAutoCancel(true)
- .setVisibility(
- if (manga.isNsfw) {
- NotificationCompat.VISIBILITY_SECRET
- } else NotificationCompat.VISIBILITY_PUBLIC,
- )
- }
- if (error != null) {
- notification.setContentTitle(getString(R.string.error_occurred))
- .setContentText(error.getDisplayMessage(resources))
- .setSmallIcon(android.R.drawable.stat_notify_error)
- } else {
- notification.setContentTitle(getString(R.string.import_completed))
- .setContentText(getString(R.string.import_completed_hint))
- .setSmallIcon(R.drawable.ic_stat_done)
- NotificationCompat.BigTextStyle(notification)
- .bigText(getString(R.string.import_completed_hint))
- }
-
- notificationManager.notify(uri.hashCode(), notification.build())
- }
-
- private fun startForeground() {
- val title = getString(R.string.importing_manga)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
- val channel = NotificationChannel(CHANNEL_ID, title, NotificationManager.IMPORTANCE_LOW)
- channel.setShowBadge(false)
- channel.enableVibration(false)
- channel.setSound(null, null)
- channel.enableLights(false)
- manager.createNotificationChannel(channel)
- }
-
- val notification = NotificationCompat.Builder(this, CHANNEL_ID)
- .setContentTitle(title)
- .setPriority(NotificationCompat.PRIORITY_MIN)
- .setDefaults(0)
- .setColor(ContextCompat.getColor(this, R.color.blue_primary_dark))
- .setSilent(true)
- .setProgress(0, 0, true)
- .setSmallIcon(android.R.drawable.stat_sys_download)
- .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
- .setOngoing(true)
- .build()
- startForeground(NOTIFICATION_ID, notification)
- }
-
- companion object {
-
- var isRunning: Boolean = false
- private set
-
- private const val CHANNEL_ID = "importing"
- private const val NOTIFICATION_ID = 22
-
- private const val EXTRA_URIS = "uris"
-
- fun start(context: Context, uris: Collection) {
- if (uris.isEmpty()) {
- return
- }
- val intent = Intent(context, ImportService::class.java)
- intent.putParcelableArrayListExtra(EXTRA_URIS, uris.asArrayList())
- ContextCompat.startForegroundService(context, intent)
- Toast.makeText(context, R.string.import_will_start_soon, Toast.LENGTH_LONG).show()
- }
- }
-}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportWorker.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportWorker.kt
new file mode 100644
index 000000000..b6d527b0d
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportWorker.kt
@@ -0,0 +1,149 @@
+package org.koitharu.kotatsu.local.ui
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.net.Uri
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import androidx.hilt.work.HiltWorker
+import androidx.work.Constraints
+import androidx.work.CoroutineWorker
+import androidx.work.Data
+import androidx.work.ForegroundInfo
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.OutOfQuotaPolicy
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import coil.ImageLoader
+import coil.request.ImageRequest
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.details.ui.DetailsActivity
+import org.koitharu.kotatsu.local.data.importer.SingleMangaImporter
+import org.koitharu.kotatsu.parsers.model.Manga
+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.toBitmapOrNull
+import org.koitharu.kotatsu.utils.ext.toUriOrNull
+
+@HiltWorker
+class ImportWorker @AssistedInject constructor(
+ @Assisted appContext: Context,
+ @Assisted params: WorkerParameters,
+ private val importer: SingleMangaImporter,
+ private val coil: ImageLoader
+) : CoroutineWorker(appContext, params) {
+
+ private val notificationManager by lazy {
+ applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ }
+
+ override suspend fun doWork(): Result {
+ val uri = inputData.getString(DATA_URI)?.toUriOrNull() ?: return Result.failure()
+ setForeground(getForegroundInfo())
+ val result = runCatchingCancellable {
+ importer.import(uri, null).manga
+ }
+ val notification = buildNotification(result)
+ notificationManager.notify(uri.hashCode(), notification)
+ return Result.success()
+ }
+
+ override suspend fun getForegroundInfo(): ForegroundInfo {
+ val title = applicationContext.getString(R.string.importing_manga)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(CHANNEL_ID, title, NotificationManager.IMPORTANCE_LOW)
+ channel.setShowBadge(false)
+ channel.enableVibration(false)
+ channel.setSound(null, null)
+ channel.enableLights(false)
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
+ .setContentTitle(title)
+ .setPriority(NotificationCompat.PRIORITY_MIN)
+ .setDefaults(0)
+ .setColor(ContextCompat.getColor(applicationContext, R.color.blue_primary_dark))
+ .setSilent(true)
+ .setProgress(0, 0, true)
+ .setSmallIcon(android.R.drawable.stat_sys_download)
+ .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
+ .setOngoing(true)
+ .build()
+
+ return ForegroundInfo(FOREGROUND_NOTIFICATION_ID, notification)
+ }
+
+ private suspend fun buildNotification(result: kotlin.Result): Notification {
+ val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setDefaults(0)
+ .setColor(ContextCompat.getColor(applicationContext, R.color.blue_primary_dark))
+ .setSilent(true)
+ result.onSuccess { manga ->
+ notification.setLargeIcon(
+ coil.execute(
+ ImageRequest.Builder(applicationContext)
+ .data(manga.coverUrl)
+ .tag(manga.source)
+ .build(),
+ ).toBitmapOrNull(),
+ )
+ notification.setSubText(manga.title)
+ val intent = DetailsActivity.newIntent(applicationContext, manga)
+ notification.setContentIntent(
+ PendingIntent.getActivity(
+ applicationContext,
+ manga.id.toInt(),
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE,
+ ),
+ ).setAutoCancel(true)
+ .setVisibility(
+ if (manga.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC,
+ )
+ notification.setContentTitle(applicationContext.getString(R.string.import_completed))
+ .setContentText(applicationContext.getString(R.string.import_completed_hint))
+ .setSmallIcon(R.drawable.ic_stat_done)
+ NotificationCompat.BigTextStyle(notification)
+ .bigText(applicationContext.getString(R.string.import_completed_hint))
+ }.onFailure { error ->
+ notification.setContentTitle(applicationContext.getString(R.string.error_occurred))
+ .setContentText(error.getDisplayMessage(applicationContext.resources))
+ .setSmallIcon(android.R.drawable.stat_notify_error)
+ }
+ return notification.build()
+ }
+
+ companion object {
+
+ const val DATA_URI = "uri"
+
+ private const val TAG = "import"
+ private const val CHANNEL_ID = "importing"
+ private const val FOREGROUND_NOTIFICATION_ID = 37
+
+ fun start(context: Context, uris: Iterable) {
+ val constraints = Constraints.Builder()
+ .setRequiresStorageNotLow(true)
+ .build()
+ val requests = uris.map { uri ->
+ OneTimeWorkRequestBuilder()
+ .setConstraints(constraints)
+ .addTag(TAG)
+ .setInputData(Data.Builder().putString(DATA_URI, uri.toString()).build())
+ .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
+ .build()
+ }
+ WorkManager.getInstance(context)
+ .enqueue(requests)
+ }
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
index 499ed4b6d..0cbab571a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
@@ -16,6 +16,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
+import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.history.domain.HistoryRepository
@@ -33,7 +34,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
-import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import java.io.IOException
@@ -46,6 +47,7 @@ class LocalListViewModel @Inject constructor(
private val historyRepository: HistoryRepository,
private val trackingRepository: TrackingRepository,
private val settings: AppSettings,
+ private val tagHighlighter: MangaTagHighlighter,
) : MangaListViewModel(settings), ListExtraProvider {
val onMangaRemoved = SingleLiveEvent()
@@ -76,10 +78,10 @@ class LocalListViewModel @Inject constructor(
else -> buildList(list.size + 1) {
add(createHeader(list, tags, order))
- list.toUi(this, mode, this@LocalListViewModel)
+ list.toUi(this, mode, this@LocalListViewModel, tagHighlighter)
}
}
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
+ }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
init {
onRefresh()
@@ -139,7 +141,7 @@ class LocalListViewModel @Inject constructor(
}
private fun cleanup() {
- if (!DownloadService.isRunning && !ImportService.isRunning && !LocalChaptersRemoveService.isRunning) {
+ if (!DownloadService.isRunning && !LocalChaptersRemoveService.isRunning) {
viewModelScope.launch {
runCatchingCancellable {
repository.cleanup()
@@ -170,7 +172,7 @@ class LocalListViewModel @Inject constructor(
val chips = LinkedList()
for ((tag, _) in topTags) {
val model = ChipsView.ChipModel(
- icon = 0,
+ tint = 0,
title = tag.title,
isCheckable = true,
isChecked = tag in selectedTags,
diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt
index 87983c024..f226c686b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt
@@ -26,14 +26,13 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.whenResumed
+import androidx.lifecycle.withResumed
import androidx.transition.TransitionManager
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP
-import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -41,6 +40,7 @@ import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.widgets.SlidingBottomNavigationView
+import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.databinding.ActivityMainBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.DetailsActivity
@@ -62,7 +62,6 @@ import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.VoiceInputContract
import org.koitharu.kotatsu.utils.ext.drawableEnd
-import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.hideKeyboard
import org.koitharu.kotatsu.utils.ext.resolve
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
@@ -132,7 +131,7 @@ class MainActivity :
}
viewModel.onOpenReader.observe(this, this::onOpenReader)
- viewModel.onError.observe(this, this::onError)
+ viewModel.onError.observe(this, SnackbarErrorObserver(binding.container, null))
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged)
viewModel.counters.observe(this, ::onCountersChanged)
@@ -252,10 +251,6 @@ class MainActivity :
startActivity(ReaderActivity.newIntent(this, manga), options)
}
- private fun onError(e: Throwable) {
- Snackbar.make(binding.container, e.getDisplayMessage(resources), Snackbar.LENGTH_SHORT).show()
- }
-
private fun onCountersChanged(counters: SparseIntArray) {
repeat(counters.size) { i ->
val id = counters.keyAt(i)
@@ -315,11 +310,11 @@ class MainActivity :
private fun onFirstStart() {
lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher
when {
- !settings.isSourcesSelected -> whenResumed {
+ !settings.isSourcesSelected -> withResumed {
OnboardDialogFragment.showWelcome(supportFragmentManager)
}
- settings.newSources.isNotEmpty() -> whenResumed {
+ settings.newSources.isNotEmpty() -> withResumed {
NewSourcesDialogFragment.show(supportFragmentManager)
}
}
@@ -327,7 +322,7 @@ class MainActivity :
TrackWorker.setup(applicationContext)
SuggestionsWorker.setup(applicationContext)
}
- whenResumed {
+ withResumed {
MangaPrefetchService.prefetchLast(this@MainActivity)
requestNotificationsPermission()
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt
index 5573fbfd4..92d71241d 100644
--- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt
@@ -15,6 +15,8 @@ import org.koitharu.kotatsu.explore.ui.ExploreFragment
import org.koitharu.kotatsu.settings.tools.ToolsFragment
import org.koitharu.kotatsu.shelf.ui.ShelfFragment
import org.koitharu.kotatsu.tracker.ui.feed.FeedFragment
+import org.koitharu.kotatsu.utils.ext.firstVisibleItemPosition
+import org.koitharu.kotatsu.utils.ext.isAnimationsEnabled
import java.util.LinkedList
private const val TAG_PRIMARY = "primary"
@@ -44,7 +46,11 @@ class MainNavigationDelegate(
return
}
val recyclerView = fragment.recyclerView
- recyclerView.smoothScrollToPosition(0)
+ if (recyclerView.context.isAnimationsEnabled) {
+ recyclerView.smoothScrollToPosition(0)
+ } else {
+ recyclerView.firstVisibleItemPosition = 0
+ }
}
override fun handleOnBackPressed() {
diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt
index 423b88fbb..978e0d235 100644
--- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt
@@ -20,7 +20,6 @@ import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
-import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import javax.inject.Inject
@HiltViewModel
@@ -56,7 +55,7 @@ class MainViewModel @Inject constructor(
a[R.id.nav_tools] = if (appUpdate != null) 1 else 0
a[R.id.nav_feed] = tracks
a
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, SparseIntArray(0))
+ }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, SparseIntArray(0))
init {
launchJob {
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/domain/ChaptersLoader.kt b/app/src/main/java/org/koitharu/kotatsu/reader/domain/ChaptersLoader.kt
index 7d3649268..ecd163e36 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/domain/ChaptersLoader.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/domain/ChaptersLoader.kt
@@ -1,16 +1,19 @@
package org.koitharu.kotatsu.reader.domain
import android.util.LongSparseArray
+import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
+import javax.inject.Inject
private const val PAGES_TRIM_THRESHOLD = 120
-class ChaptersLoader(
+@ViewModelScoped
+class ChaptersLoader @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt
index 14fc6acdb..aac110c48 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt
@@ -5,20 +5,20 @@ import android.graphics.BitmapFactory
import android.net.Uri
import androidx.collection.LongSparseArray
import androidx.collection.set
+import dagger.hilt.android.ActivityRetainedLifecycle
+import dagger.hilt.android.lifecycle.RetainedLifecycle
+import dagger.hilt.android.scopes.ActivityRetainedScoped
import kotlinx.coroutines.CoroutineExceptionHandler
-import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
-import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
+import kotlinx.coroutines.plus
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import okhttp3.Request
-import okio.Closeable
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
@@ -28,6 +28,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
+import org.koitharu.kotatsu.utils.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.withProgress
import org.koitharu.kotatsu.utils.progress.ProgressDeferred
@@ -42,14 +43,20 @@ import kotlin.coroutines.CoroutineContext
private const val PROGRESS_UNDEFINED = -1f
private const val PREFETCH_LIMIT_DEFAULT = 10
+@ActivityRetainedScoped
class PageLoader @Inject constructor(
+ lifecycle: ActivityRetainedLifecycle,
private val okHttp: OkHttpClient,
private val cache: PagesCache,
private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory,
-) : Closeable {
+) : RetainedLifecycle.OnClearedListener {
- val loaderScope = CoroutineScope(SupervisorJob() + InternalErrorHandler() + Dispatchers.Default)
+ init {
+ lifecycle.addOnClearedListener(this)
+ }
+
+ val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default
private val tasks = LongSparseArray>()
private val convertLock = Mutex()
@@ -59,8 +66,7 @@ class PageLoader @Inject constructor(
private val counter = AtomicInteger(0)
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
- override fun close() {
- loaderScope.cancel()
+ override fun onCleared() {
synchronized(tasks) {
tasks.clear()
}
@@ -186,7 +192,7 @@ class PageLoader @Inject constructor(
.url(pageUrl)
.get()
.header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8")
- .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
+ .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
.tag(MangaSource::class.java, page.source)
.build()
okHttp.newCall(request).await().use { response ->
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt
index e4f97f1f5..203326400 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt
@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.reader.ui
import android.content.Context
-import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Bundle
@@ -16,6 +15,7 @@ import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
+import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.WindowInsetsCompat
@@ -23,7 +23,6 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
@@ -34,14 +33,13 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseFullscreenActivity
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
-import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
+import org.koitharu.kotatsu.core.exceptions.resolve.DialogErrorObserver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.databinding.ActivityReaderBinding
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
-import org.koitharu.kotatsu.reader.ui.config.PageSwitchTimer
import org.koitharu.kotatsu.reader.ui.config.ReaderConfigBottomSheet
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
@@ -50,14 +48,9 @@ import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.utils.GridTouchHelper
import org.koitharu.kotatsu.utils.IdlingDetector
import org.koitharu.kotatsu.utils.ShareHelper
-import org.koitharu.kotatsu.utils.ext.assistedViewModels
-import org.koitharu.kotatsu.utils.ext.getDisplayMessage
-import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.utils.ext.hasGlobalPoint
-import org.koitharu.kotatsu.utils.ext.isReportable
import org.koitharu.kotatsu.utils.ext.observeWithPrevious
import org.koitharu.kotatsu.utils.ext.postDelayed
-import org.koitharu.kotatsu.utils.ext.report
import org.koitharu.kotatsu.utils.ext.setValueRounded
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@@ -73,29 +66,23 @@ class ReaderActivity :
OnApplyWindowInsetsListener,
IdlingDetector.Callback {
- @Inject
- lateinit var viewModelFactory: ReaderViewModel.Factory
-
private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this)
- val viewModel by assistedViewModels {
- viewModelFactory.create(
- intent = MangaIntent(intent),
- initialState = intent?.getParcelableExtraCompat(EXTRA_STATE),
- preselectedBranch = intent?.getStringExtra(EXTRA_BRANCH),
- )
- }
+ private val viewModel: ReaderViewModel by viewModels()
- override var pageSwitchDelay: Float
- get() = pageSwitchTimer.delaySec
+ override val readerMode: ReaderMode?
+ get() = readerManager.currentMode
+
+ override var isAutoScrollEnabled: Boolean
+ get() = scrollTimer.isEnabled
set(value) {
- pageSwitchTimer.delaySec = value
+ scrollTimer.isEnabled = value
}
- override val readerMode: ReaderMode?
- get() = readerManager.currentMode
+ @Inject
+ lateinit var scrollTimerFactory: ScrollTimer.Factory
- private lateinit var pageSwitchTimer: PageSwitchTimer
+ private lateinit var scrollTimer: ScrollTimer
private lateinit var touchHelper: GridTouchHelper
private lateinit var controlDelegate: ReaderControlDelegate
private var gestureInsets: Insets = Insets.NONE
@@ -108,7 +95,7 @@ class ReaderActivity :
readerManager = ReaderManager(supportFragmentManager, R.id.container)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
touchHelper = GridTouchHelper(this, this)
- pageSwitchTimer = PageSwitchTimer(this, this)
+ scrollTimer = scrollTimerFactory.create(this, this)
controlDelegate = ReaderControlDelegate(settings, this, this)
binding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected)
binding.slider.setLabelFormatter(PageLabelFormatter())
@@ -116,7 +103,21 @@ class ReaderActivity :
insetsDelegate.interceptingWindowInsetsListener = this
idlingDetector.bindToLifecycle(this)
- viewModel.onError.observe(this, this::onError)
+ viewModel.onError.observe(
+ this,
+ DialogErrorObserver(
+ host = binding.container,
+ fragment = null,
+ resolver = exceptionResolver,
+ onResolved = { isResolved ->
+ if (isResolved) {
+ viewModel.reload()
+ } else if (viewModel.content.value?.pages.isNullOrEmpty()) {
+ finishAfterTransition()
+ }
+ },
+ ),
+ )
viewModel.readerMode.observe(this, this::onInitReader)
viewModel.onPageSaved.observe(this, this::onPageSaved)
viewModel.uiState.observeWithPrevious(this, this::onUiStateChanged)
@@ -136,7 +137,7 @@ class ReaderActivity :
override fun onUserInteraction() {
super.onUserInteraction()
- pageSwitchTimer.onUserInteraction()
+ scrollTimer.onUserInteraction()
idlingDetector.onUserInteraction()
}
@@ -218,22 +219,6 @@ class ReaderActivity :
menu.findItem(R.id.action_pages_thumbs).isVisible = hasPages
}
- private fun onError(e: Throwable) {
- val listener = ErrorDialogListener(e)
- val dialog = MaterialAlertDialogBuilder(this)
- .setTitle(R.string.error_occurred)
- .setMessage(e.getDisplayMessage(resources))
- .setNegativeButton(R.string.close, listener)
- .setOnCancelListener(listener)
- val resolveTextId = ExceptionResolver.getResolveStringId(e)
- if (resolveTextId != 0) {
- dialog.setPositiveButton(resolveTextId, listener)
- } else if (e.isReportable()) {
- dialog.setPositiveButton(R.string.report, listener)
- }
- dialog.show()
- }
-
override fun onGridTouch(area: Int) {
controlDelegate.onGridTouch(area, binding.container)
}
@@ -355,10 +340,19 @@ class ReaderActivity :
readerManager.currentReader?.switchPageBy(delta)
}
+ override fun scrollBy(delta: Int): Boolean {
+ return readerManager.currentReader?.scrollBy(delta) ?: false
+ }
+
override fun toggleUiVisibility() {
setUiIsVisible(!binding.appbarTop.isVisible)
}
+ override fun isReaderResumed(): Boolean {
+ val reader = readerManager.currentReader ?: return false
+ return reader.isResumed && supportFragmentManager.fragments.lastOrNull() === reader
+ }
+
private fun onReaderBarChanged(isBarEnabled: Boolean) {
binding.infoBar.isVisible = isBarEnabled && binding.appbarTop.isGone
}
@@ -396,45 +390,12 @@ class ReaderActivity :
}
}
- private inner class ErrorDialogListener(
- private val exception: Throwable,
- ) : DialogInterface.OnClickListener, DialogInterface.OnCancelListener {
-
- override fun onClick(dialog: DialogInterface?, which: Int) {
- if (which == DialogInterface.BUTTON_POSITIVE) {
- dialog?.dismiss()
- if (ExceptionResolver.canResolve(exception)) {
- tryResolve(exception)
- } else {
- exception.report()
- }
- } else {
- onCancel(dialog)
- }
- }
-
- override fun onCancel(dialog: DialogInterface?) {
- if (viewModel.content.value?.pages.isNullOrEmpty()) {
- finishAfterTransition()
- }
- }
-
- private fun tryResolve(e: Throwable) {
- lifecycleScope.launch {
- if (exceptionResolver.resolve(e)) {
- viewModel.reload()
- } else {
- onCancel(null)
- }
- }
- }
- }
-
companion object {
const val ACTION_MANGA_READ = "${BuildConfig.APPLICATION_ID}.action.READ_MANGA"
- private const val EXTRA_STATE = "state"
- private const val EXTRA_BRANCH = "branch"
+ const val EXTRA_STATE = "state"
+ const val EXTRA_BRANCH = "branch"
+ const val EXTRA_INCOGNITO = "incognito"
private const val TOAST_DURATION = 1500L
fun newIntent(context: Context, manga: Manga): Intent {
@@ -455,8 +416,13 @@ class ReaderActivity :
}
fun newIntent(context: Context, bookmark: Bookmark): Intent {
- val state = ReaderState(bookmark.chapterId, bookmark.page, bookmark.scroll)
+ val state = ReaderState(
+ chapterId = bookmark.chapterId,
+ page = bookmark.page,
+ scroll = bookmark.scroll,
+ )
return newIntent(context, bookmark.manga, state)
+ .putExtra(EXTRA_INCOGNITO, true)
}
fun newIntent(context: Context, mangaId: Long): Intent {
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt
index 3da6ed8d1..3791ac5c4 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt
@@ -42,18 +42,22 @@ class ReaderControlDelegate(
listener.toggleUiVisibility()
view.playSoundEffect(SoundEffectConstants.CLICK)
}
+
GridTouchHelper.AREA_TOP -> if (isTapSwitchEnabled) {
listener.switchPageBy(-1)
view.playSoundEffect(SoundEffectConstants.NAVIGATION_UP)
}
+
GridTouchHelper.AREA_LEFT -> if (isTapSwitchEnabled) {
listener.switchPageBy(if (isReaderTapsReversed()) 1 else -1)
view.playSoundEffect(SoundEffectConstants.NAVIGATION_LEFT)
}
+
GridTouchHelper.AREA_BOTTOM -> if (isTapSwitchEnabled) {
listener.switchPageBy(1)
view.playSoundEffect(SoundEffectConstants.NAVIGATION_DOWN)
}
+
GridTouchHelper.AREA_RIGHT -> if (isTapSwitchEnabled) {
listener.switchPageBy(if (isReaderTapsReversed()) -1 else 1)
view.playSoundEffect(SoundEffectConstants.NAVIGATION_RIGHT)
@@ -68,12 +72,14 @@ class ReaderControlDelegate(
} else {
false
}
+
KeyEvent.KEYCODE_VOLUME_DOWN -> if (isVolumeKeysSwitchEnabled) {
listener.switchPageBy(1)
true
} else {
false
}
+
KeyEvent.KEYCODE_SPACE,
KeyEvent.KEYCODE_PAGE_DOWN,
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN,
@@ -82,10 +88,12 @@ class ReaderControlDelegate(
listener.switchPageBy(1)
true
}
+
KeyEvent.KEYCODE_DPAD_RIGHT -> {
listener.switchPageBy(if (isReaderTapsReversed()) -1 else 1)
true
}
+
KeyEvent.KEYCODE_PAGE_UP,
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP,
KeyEvent.KEYCODE_DPAD_UP,
@@ -93,14 +101,17 @@ class ReaderControlDelegate(
listener.switchPageBy(-1)
true
}
+
KeyEvent.KEYCODE_DPAD_LEFT -> {
listener.switchPageBy(if (isReaderTapsReversed()) 1 else -1)
true
}
+
KeyEvent.KEYCODE_DPAD_CENTER -> {
listener.toggleUiVisibility()
true
}
+
else -> false
}
@@ -128,6 +139,10 @@ class ReaderControlDelegate(
fun switchPageBy(delta: Int)
+ fun scrollBy(delta: Int): Boolean
+
fun toggleUiVisibility()
+
+ fun isReaderResumed(): Boolean
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt
index d059e1325..4f17d170b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt
@@ -6,10 +6,9 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.AnyThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -56,35 +55,36 @@ import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.requireValue
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import java.util.Date
-import javax.inject.Provider
+import javax.inject.Inject
private const val BOUNDS_PAGE_OFFSET = 2
private const val PREFETCH_LIMIT = 10
-class ReaderViewModel @AssistedInject constructor(
- @Assisted private val intent: MangaIntent,
- @Assisted initialState: ReaderState?,
- @Assisted private val preselectedBranch: String?,
+@HiltViewModel
+class ReaderViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val dataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository,
private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings,
private val pageSaveHelper: PageSaveHelper,
- pageLoaderFactory: Provider,
+ private val pageLoader: PageLoader,
+ private val chaptersLoader: ChaptersLoader,
) : BaseViewModel() {
+ private val intent = MangaIntent(savedStateHandle)
+ private val preselectedBranch = savedStateHandle.get(ReaderActivity.EXTRA_BRANCH)
+ private val isIncognito = savedStateHandle.get(ReaderActivity.EXTRA_INCOGNITO) ?: false
+
private var loadingJob: Job? = null
private var pageSaveJob: Job? = null
private var bookmarkJob: Job? = null
- private val currentState = MutableStateFlow(initialState)
+ private val currentState = MutableStateFlow(savedStateHandle[ReaderActivity.EXTRA_STATE])
private val mangaData = MutableStateFlow(intent.manga)
private val chapters: LongSparseArray
get() = chaptersLoader.chapters
- val pageLoader = pageLoaderFactory.get()
- private val chaptersLoader = ChaptersLoader(mangaRepositoryFactory)
-
val readerMode = MutableLiveData()
val onPageSaved = SingleLiveEvent()
val onShowToast = SingleLiveEvent()
@@ -146,11 +146,6 @@ class ReaderViewModel @AssistedInject constructor(
}.launchIn(viewModelScope)
}
- override fun onCleared() {
- pageLoader.close()
- super.onCleared()
- }
-
fun reload() {
loadingJob?.cancel()
loadImpl()
@@ -176,6 +171,9 @@ class ReaderViewModel @AssistedInject constructor(
if (state != null) {
currentState.value = state
}
+ if (isIncognito) {
+ return
+ }
val readerState = state ?: currentState.value ?: return
historyRepository.saveStateAsync(
manga = mangaData.value ?: return,
@@ -319,9 +317,11 @@ class ReaderViewModel @AssistedInject constructor(
chaptersLoader.loadSingleChapter(manga, requireNotNull(currentState.value).chapterId)
// save state
- currentState.value?.let {
- val percent = computePercent(it.chapterId, it.page)
- historyRepository.addOrUpdate(manga, it.chapterId, it.page, it.scroll, percent)
+ if (!isIncognito) {
+ currentState.value?.let {
+ val percent = computePercent(it.chapterId, it.page)
+ historyRepository.addOrUpdate(manga, it.chapterId, it.page, it.scroll, percent)
+ }
}
notifyStateChanged()
content.postValue(ReaderContent(chaptersLoader.snapshot(), currentState.value))
@@ -382,7 +382,8 @@ class ReaderViewModel @AssistedInject constructor(
}
private fun computePercent(chapterId: Long, pageIndex: Int): Float {
- val chapters = manga?.chapters ?: return PROGRESS_NONE
+ val branch = chapters[chapterId]?.branch
+ val chapters = manga?.getChapters(branch) ?: return PROGRESS_NONE
val chaptersCount = chapters.size
val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId }
val pagesCount = chaptersLoader.getPagesCount(chapterId)
@@ -393,16 +394,6 @@ class ReaderViewModel @AssistedInject constructor(
val ppc = 1f / chaptersCount
return ppc * chapterIndex + ppc * pagePercent
}
-
- @AssistedFactory
- interface Factory {
-
- fun create(
- intent: MangaIntent,
- initialState: ReaderState?,
- preselectedBranch: String?,
- ): ReaderViewModel
- }
}
/**
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ScrollTimer.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ScrollTimer.kt
new file mode 100644
index 000000000..c9cdb3516
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ScrollTimer.kt
@@ -0,0 +1,128 @@
+package org.koitharu.kotatsu.reader.ui
+
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.core.prefs.observeAsFlow
+import kotlin.math.roundToLong
+
+private const val MAX_DELAY = 20L
+private const val MAX_SWITCH_DELAY = 10_000L
+private const val INTERACTION_SKIP_MS = 2_000L
+private const val SPEED_FACTOR_DELTA = 0.02f
+
+class ScrollTimer @AssistedInject constructor(
+ @Assisted private val listener: ReaderControlDelegate.OnInteractionListener,
+ @Assisted lifecycleOwner: LifecycleOwner,
+ settings: AppSettings,
+) {
+
+ private val coroutineScope = lifecycleOwner.lifecycleScope
+ private var job: Job? = null
+ private var delayMs: Long = 10L
+ private var pageSwitchDelay: Long = 100L
+ private var resumeAt = 0L
+
+ var isEnabled: Boolean = false
+ set(value) {
+ if (field != value) {
+ field = value
+ restartJob()
+ }
+ }
+
+ init {
+ settings.observeAsFlow(AppSettings.KEY_READER_AUTOSCROLL_SPEED) {
+ readerAutoscrollSpeed
+ }.flowOn(Dispatchers.Default)
+ .onEach {
+ onSpeedChanged(it)
+ }.launchIn(coroutineScope)
+ }
+
+ fun onUserInteraction() {
+ resumeAt = System.currentTimeMillis() + INTERACTION_SKIP_MS
+ }
+
+ private fun onSpeedChanged(speed: Float) {
+ if (speed <= 0f) {
+ delayMs = 0L
+ pageSwitchDelay = 0L
+ } else {
+ val speedFactor = 1 - speed
+ delayMs = (MAX_DELAY * speedFactor).roundToLong()
+ pageSwitchDelay = (MAX_SWITCH_DELAY * speedFactor).roundToLong()
+ }
+ if ((job == null) != (delayMs == 0L)) {
+ restartJob()
+ }
+ }
+
+ private fun restartJob() {
+ job?.cancel()
+ resumeAt = 0L
+ if (!isEnabled || delayMs == 0L) {
+ job = null
+ return
+ }
+ job = coroutineScope.launch {
+ var accumulator = 0L
+ var speedFactor = 1f
+ while (isActive) {
+ if (isPaused()) {
+ speedFactor = (speedFactor - SPEED_FACTOR_DELTA).coerceAtLeast(0f)
+ } else if (speedFactor < 1f) {
+ speedFactor = (speedFactor + SPEED_FACTOR_DELTA).coerceAtMost(1f)
+ }
+ if (speedFactor == 1f) {
+ delay(delayMs)
+ } else if (speedFactor == 0f) {
+ delayUntilResumed()
+ continue
+ } else {
+ delay((delayMs * (1f + speedFactor * 2)).toLong())
+ }
+ if (!listener.isReaderResumed()) {
+ continue
+ }
+ if (!listener.scrollBy(1)) {
+ accumulator += delayMs
+ }
+ if (accumulator >= pageSwitchDelay) {
+ listener.switchPageBy(1)
+ accumulator -= pageSwitchDelay
+ }
+ }
+ }
+ }
+
+ private fun isPaused(): Boolean {
+ return resumeAt > System.currentTimeMillis()
+ }
+
+ private suspend fun delayUntilResumed() {
+ while (isPaused()) {
+ delay(resumeAt - System.currentTimeMillis())
+ }
+ }
+
+ @AssistedFactory
+ interface Factory {
+
+ fun create(
+ lifecycleOwner: LifecycleOwner,
+ listener: ReaderControlDelegate.OnInteractionListener,
+ ): ScrollTimer
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt
index ec57f5847..d90e4f705 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt
@@ -6,6 +6,7 @@ import android.content.res.Resources
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
+import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
@@ -25,10 +26,8 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
-import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.decodeRegion
import org.koitharu.kotatsu.utils.ext.enqueueWith
-import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.utils.ext.setValueRounded
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -42,15 +41,7 @@ class ColorFilterConfigActivity :
@Inject
lateinit var coil: ImageLoader
- @Inject
- lateinit var viewModelFactory: ColorFilterConfigViewModel.Factory
-
- private val viewModel: ColorFilterConfigViewModel by assistedViewModels {
- viewModelFactory.create(
- manga = checkNotNull(intent.getParcelableExtraCompat(EXTRA_MANGA)?.manga),
- page = checkNotNull(intent.getParcelableExtraCompat(EXTRA_PAGES)?.pages?.firstOrNull()),
- )
- }
+ private val viewModel: ColorFilterConfigViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -144,8 +135,8 @@ class ColorFilterConfigActivity :
companion object {
- private const val EXTRA_PAGES = "pages"
- private const val EXTRA_MANGA = "manga_id"
+ const val EXTRA_PAGES = "pages"
+ const val EXTRA_MANGA = "manga_id"
fun newIntent(context: Context, manga: Manga, page: MangaPage) =
Intent(context, ColorFilterConfigActivity::class.java)
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt
index 31bd74f3c..3f1a4dde5 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt
@@ -1,24 +1,29 @@
package org.koitharu.kotatsu.reader.ui.colorfilter
import androidx.lifecycle.MutableLiveData
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
+import androidx.lifecycle.SavedStateHandle
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.ui.BaseViewModel
+import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
+import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPages
import org.koitharu.kotatsu.core.parser.MangaRepository
-import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
+import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity.Companion.EXTRA_MANGA
import org.koitharu.kotatsu.utils.SingleLiveEvent
+import javax.inject.Inject
-class ColorFilterConfigViewModel @AssistedInject constructor(
- @Assisted private val manga: Manga,
- @Assisted page: MangaPage,
+@HiltViewModel
+class ColorFilterConfigViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val mangaDataRepository: MangaDataRepository,
) : BaseViewModel() {
+ private val manga = checkNotNull(savedStateHandle.get(EXTRA_MANGA)?.manga)
+
private var initialColorFilter: ReaderColorFilter? = null
val colorFilter = MutableLiveData(null)
val onDismiss = SingleLiveEvent()
@@ -28,18 +33,23 @@ class ColorFilterConfigViewModel @AssistedInject constructor(
get() = colorFilter.value != initialColorFilter
init {
+ val page = checkNotNull(
+ savedStateHandle.get(ColorFilterConfigActivity.EXTRA_PAGES)?.pages?.firstOrNull(),
+ )
launchLoadingJob {
initialColorFilter = mangaDataRepository.getColorFilter(manga.id)
colorFilter.value = initialColorFilter
}
- launchLoadingJob {
+ launchLoadingJob(Dispatchers.Default) {
val repository = mangaRepositoryFactory.create(page.source)
val url = repository.getPageUrl(page)
- preview.value = MangaPage(
- id = page.id,
- url = url,
- preview = page.preview,
- source = page.source,
+ preview.postValue(
+ MangaPage(
+ id = page.id,
+ url = url,
+ preview = page.preview,
+ source = page.source,
+ ),
)
}
}
@@ -59,15 +69,9 @@ class ColorFilterConfigViewModel @AssistedInject constructor(
}
fun save() {
- launchLoadingJob {
+ launchLoadingJob(Dispatchers.Default) {
mangaDataRepository.saveColorFilter(manga, colorFilter.value)
- onDismiss.call(Unit)
+ onDismiss.postCall(Unit)
}
}
-
- @AssistedFactory
- interface Factory {
-
- fun create(manga: Manga, page: MangaPage): ColorFilterConfigViewModel
- }
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/config/PageSwitchTimer.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/config/PageSwitchTimer.kt
deleted file mode 100644
index 2bc82623c..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/config/PageSwitchTimer.kt
+++ /dev/null
@@ -1,74 +0,0 @@
-package org.koitharu.kotatsu.reader.ui.config
-
-import android.content.res.Resources
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.LifecycleOwner
-import androidx.lifecycle.coroutineScope
-import androidx.lifecycle.repeatOnLifecycle
-import com.google.android.material.slider.LabelFormatter
-import kotlin.math.roundToLong
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.parsers.util.format
-import org.koitharu.kotatsu.reader.ui.ReaderControlDelegate
-
-class PageSwitchTimer(
- private val listener: ReaderControlDelegate.OnInteractionListener,
- private val lifecycleOwner: LifecycleOwner,
-) {
-
- var delaySec: Float = 0f
- set(value) {
- field = value
- delayMs = mapDelay(value)
- restartJob()
- }
- private var delayMs = 0L
-
- fun onUserInteraction() {
- restartJob()
- }
-
- private var job: Job? = null
-
- private fun restartJob() {
- job?.cancel()
- if (delayMs == 0L) {
- job = null
- return
- }
- job = lifecycleOwner.lifecycle.coroutineScope.launch {
- // FIXME: pause when bs is opened
- lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
- while (isActive) {
- delay(delayMs)
- listener.switchPageBy(1)
- }
- }
- }
- }
-
- class DelayLabelFormatter(resources: Resources) : LabelFormatter {
-
- private val textOff = resources.getString(R.string.off_short)
- private val textSec = resources.getString(R.string.seconds_pattern)
-
- override fun getFormattedValue(value: Float): String {
- val ms = mapDelay(value)
- return if (ms == 0L) textOff else textSec.format((ms / 1000.0).format(1))
- }
- }
-
- companion object {
-
- private const val DELAY_MIN = 2000L
-
- fun mapDelay(value: Float): Long {
- val delay = (value * 1000L).roundToLong()
- return if (delay < DELAY_MIN) 0L else delay
- }
- }
-}
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/config/ReaderConfigBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/config/ReaderConfigBottomSheet.kt
index 51b33d0b3..139902b4f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/config/ReaderConfigBottomSheet.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/config/ReaderConfigBottomSheet.kt
@@ -5,40 +5,51 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.widget.CompoundButton
import androidx.activity.result.ActivityResultCallback
import androidx.core.view.isGone
+import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButtonToggleGroup
import com.google.android.material.slider.Slider
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
+import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode
+import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding
import org.koitharu.kotatsu.reader.ui.PageSaveContract
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.utils.ScreenOrientationHelper
-import org.koitharu.kotatsu.utils.ext.setValueRounded
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import org.koitharu.kotatsu.utils.ext.withArgs
+import javax.inject.Inject
+@AndroidEntryPoint
class ReaderConfigBottomSheet :
BaseBottomSheet(),
ActivityResultCallback,
View.OnClickListener,
MaterialButtonToggleGroup.OnButtonCheckedListener,
- Slider.OnSliderTouchListener {
+ Slider.OnChangeListener, CompoundButton.OnCheckedChangeListener {
private val viewModel by activityViewModels()
private val savePageRequest = registerForActivityResult(PageSaveContract(), this)
private var orientationHelper: ScreenOrientationHelper? = null
private lateinit var mode: ReaderMode
+ @Inject
+ lateinit var settings: AppSettings
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mode = arguments?.getInt(ARG_MODE)
@@ -62,11 +73,21 @@ class ReaderConfigBottomSheet :
binding.buttonScreenRotate.setOnClickListener(this)
binding.buttonSettings.setOnClickListener(this)
binding.buttonColorFilter.setOnClickListener(this)
- binding.sliderTimer.addOnSliderTouchListener(this)
- binding.sliderTimer.setLabelFormatter(PageSwitchTimer.DelayLabelFormatter(view.resources))
-
+ binding.sliderTimer.addOnChangeListener(this)
+ binding.switchScrollTimer.setOnCheckedChangeListener(this)
+
+ settings.observeAsLiveData(
+ context = lifecycleScope.coroutineContext + Dispatchers.Default,
+ key = AppSettings.KEY_READER_AUTOSCROLL_SPEED,
+ valueProducer = { readerAutoscrollSpeed },
+ ).observe(viewLifecycleOwner) {
+ binding.sliderTimer.value = it.coerceIn(
+ binding.sliderTimer.valueFrom,
+ binding.sliderTimer.valueTo,
+ )
+ }
findCallback()?.run {
- binding.sliderTimer.setValueRounded(pageSwitchDelay)
+ binding.switchScrollTimer.isChecked = isAutoScrollEnabled
}
}
@@ -94,6 +115,16 @@ class ReaderConfigBottomSheet :
}
}
+ override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
+ when (buttonView.id) {
+ R.id.switch_scroll_timer -> {
+ findCallback()?.isAutoScrollEnabled = isChecked
+ binding.labelTimer.isVisible = isChecked
+ binding.sliderTimer.isVisible = isChecked
+ }
+ }
+ }
+
override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) {
if (!isChecked) {
return
@@ -111,10 +142,10 @@ class ReaderConfigBottomSheet :
mode = newMode
}
- override fun onStartTrackingTouch(slider: Slider) = Unit
-
- override fun onStopTrackingTouch(slider: Slider) {
- findCallback()?.pageSwitchDelay = slider.value
+ override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
+ if (fromUser) {
+ settings.readerAutoscrollSpeed = value
+ }
}
override fun onActivityResult(uri: Uri?) {
@@ -138,7 +169,7 @@ class ReaderConfigBottomSheet :
interface Callback {
- var pageSwitchDelay: Float
+ var isAutoScrollEnabled: Boolean
fun onReaderModeChanged(mode: ReaderMode)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReader.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReader.kt
index 6bd234afd..eee0769bd 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReader.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReader.kt
@@ -51,6 +51,8 @@ abstract class BaseReader : BaseFragment() {
abstract fun switchPageTo(position: Int, smooth: Boolean)
+ open fun scrollBy(delta: Int): Boolean = false
+
abstract fun getCurrentState(): ReaderState?
protected abstract fun onPagesChanged(pages: List, pendingState: ReaderState?)
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt
index 43b517fff..a517f630a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt
@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.reader.ui.pager
-import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import androidx.lifecycle.Observer
@@ -60,9 +59,9 @@ class PageHolderDelegate(
}
}
- fun showErrorDetails(context: Context) {
+ fun showErrorDetails(url: String?) {
val e = error ?: return
- ExceptionResolver.showDetails(context, e)
+ exceptionResolver.showDetails(e, url)
}
fun onAttachedToWindow() {
@@ -104,7 +103,7 @@ class PageHolderDelegate(
}
}
- override fun onChanged(t: ReaderSettings?) {
+ override fun onChanged(value: ReaderSettings) {
if (state == State.SHOWN) {
callback.onImageShowing(readerSettings)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt
index d8f7b9b99..1447a4b36 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt
@@ -8,14 +8,17 @@ import android.view.ViewGroup
import androidx.core.view.children
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async
+import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding
+import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReader
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
import org.koitharu.kotatsu.utils.ext.doOnPageChanged
+import org.koitharu.kotatsu.utils.ext.isAnimationsEnabled
import org.koitharu.kotatsu.utils.ext.recyclerView
import org.koitharu.kotatsu.utils.ext.resetTransformations
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
@@ -28,6 +31,9 @@ class ReversedReaderFragment : BaseReader() {
@Inject
lateinit var networkState: NetworkState
+ @Inject
+ lateinit var pageLoader: PageLoader
+
private var pagerAdapter: ReversedPagesAdapter? = null
override fun onInflateView(
@@ -40,7 +46,7 @@ class ReversedReaderFragment : BaseReader() {
super.onViewCreated(view, savedInstanceState)
pagerAdapter = ReversedPagesAdapter(
lifecycleOwner = viewLifecycleOwner,
- loader = viewModel.pageLoader,
+ loader = pageLoader,
settings = viewModel.readerSettings,
networkState = networkState,
exceptionResolver = exceptionResolver,
@@ -69,20 +75,22 @@ class ReversedReaderFragment : BaseReader() {
override fun switchPageBy(delta: Int) {
with(binding.pager) {
- setCurrentItem(currentItem - delta, true)
+ setCurrentItem(currentItem - delta, context.isAnimationsEnabled)
}
}
override fun switchPageTo(position: Int, smooth: Boolean) {
- binding.pager.setCurrentItem(
- reversed(position),
- smooth && (binding.pager.currentItem - position).absoluteValue < PagerReaderFragment.SMOOTH_SCROLL_LIMIT,
- )
+ with(binding.pager) {
+ setCurrentItem(
+ reversed(position),
+ smooth && context.isAnimationsEnabled && (currentItem - position).absoluteValue < PagerReaderFragment.SMOOTH_SCROLL_LIMIT,
+ )
+ }
}
override fun onPagesChanged(pages: List, pendingState: ReaderState?) {
val reversedPages = pages.asReversed()
- viewLifecycleScope.launchWhenCreated {
+ viewLifecycleScope.launch {
val items = async {
pagerAdapter?.setItems(reversedPages)
}
@@ -90,7 +98,7 @@ class ReversedReaderFragment : BaseReader() {
val position = reversedPages.indexOfLast {
it.chapterId == pendingState.chapterId && it.index == pendingState.page
}
- items.await() ?: return@launchWhenCreated
+ items.await() ?: return@launch
if (position != -1) {
binding.pager.setCurrentItem(position, false)
notifyPageChanged(position)
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt
index 073fa0185..edf8bce98 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt
@@ -51,7 +51,7 @@ open class PageHolder(
override fun onLoadingStarted() {
bindingInfo.layoutError.isVisible = false
- bindingInfo.progressBar.showCompat()
+ bindingInfo.progressBar.show()
binding.ssiv.recycle()
}
@@ -109,13 +109,13 @@ open class PageHolder(
}
override fun onImageShown() {
- bindingInfo.progressBar.hideCompat()
+ bindingInfo.progressBar.hide()
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return)
- R.id.button_error_details -> delegate.showErrorDetails(v.context)
+ R.id.button_error_details -> delegate.showErrorDetails(boundData?.url)
}
}
@@ -125,6 +125,6 @@ open class PageHolder(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
)
bindingInfo.layoutError.isVisible = true
- bindingInfo.progressBar.hideCompat()
+ bindingInfo.progressBar.hide()
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt
index af20be177..49b4e1239 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt
@@ -8,13 +8,16 @@ import android.view.ViewGroup
import androidx.core.view.children
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async
+import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding
+import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReader
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.doOnPageChanged
+import org.koitharu.kotatsu.utils.ext.isAnimationsEnabled
import org.koitharu.kotatsu.utils.ext.recyclerView
import org.koitharu.kotatsu.utils.ext.resetTransformations
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
@@ -27,6 +30,9 @@ class PagerReaderFragment : BaseReader() {
@Inject
lateinit var networkState: NetworkState
+ @Inject
+ lateinit var pageLoader: PageLoader
+
private var pagesAdapter: PagesAdapter? = null
override fun onInflateView(
@@ -39,7 +45,7 @@ class PagerReaderFragment : BaseReader() {
super.onViewCreated(view, savedInstanceState)
pagesAdapter = PagesAdapter(
lifecycleOwner = viewLifecycleOwner,
- loader = viewModel.pageLoader,
+ loader = pageLoader,
settings = viewModel.readerSettings,
networkState = networkState,
exceptionResolver = exceptionResolver,
@@ -67,7 +73,7 @@ class PagerReaderFragment : BaseReader() {
}
override fun onPagesChanged(pages: List, pendingState: ReaderState?) {
- viewLifecycleScope.launchWhenCreated {
+ viewLifecycleScope.launch {
val items = async {
pagesAdapter?.setItems(pages)
}
@@ -75,7 +81,7 @@ class PagerReaderFragment : BaseReader() {
val position = pages.indexOfFirst {
it.chapterId == pendingState.chapterId && it.index == pendingState.page
}
- items.await() ?: return@launchWhenCreated
+ items.await() ?: return@launch
if (position != -1) {
binding.pager.setCurrentItem(position, false)
notifyPageChanged(position)
@@ -88,15 +94,17 @@ class PagerReaderFragment : BaseReader() {
override fun switchPageBy(delta: Int) {
with(binding.pager) {
- setCurrentItem(currentItem + delta, true)
+ setCurrentItem(currentItem + delta, context.isAnimationsEnabled)
}
}
override fun switchPageTo(position: Int, smooth: Boolean) {
- binding.pager.setCurrentItem(
- position,
- smooth && (binding.pager.currentItem - position).absoluteValue < SMOOTH_SCROLL_LIMIT,
- )
+ with(binding.pager) {
+ setCurrentItem(
+ position,
+ smooth && context.isAnimationsEnabled && (currentItem - position).absoluteValue < SMOOTH_SCROLL_LIMIT,
+ )
+ }
}
override fun getCurrentState(): ReaderState? = bindingOrNull()?.run {
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt
index 4218e0533..1301a96b0 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt
@@ -17,9 +17,7 @@ import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.GoneOnInvisibleListener
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
-import org.koitharu.kotatsu.utils.ext.hideCompat
import org.koitharu.kotatsu.utils.ext.ifZero
-import org.koitharu.kotatsu.utils.ext.showCompat
class WebtoonHolder(
owner: LifecycleOwner,
@@ -63,7 +61,7 @@ class WebtoonHolder(
override fun onLoadingStarted() {
bindingInfo.layoutError.isVisible = false
- bindingInfo.progressBar.showCompat()
+ bindingInfo.progressBar.show()
binding.ssiv.recycle()
}
@@ -98,13 +96,13 @@ class WebtoonHolder(
}
override fun onImageShown() {
- bindingInfo.progressBar.hideCompat()
+ bindingInfo.progressBar.hide()
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return)
- R.id.button_error_details -> delegate.showErrorDetails(v.context)
+ R.id.button_error_details -> delegate.showErrorDetails(boundData?.url)
}
}
@@ -114,7 +112,7 @@ class WebtoonHolder(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
)
bindingInfo.layoutError.isVisible = true
- bindingInfo.progressBar.hideCompat()
+ bindingInfo.progressBar.hide()
}
fun getScrollY() = binding.ssiv.getScroll()
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt
index 072e42169..226961f32 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt
@@ -7,14 +7,17 @@ import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async
+import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding
+import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReader
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.findCenterViewPosition
import org.koitharu.kotatsu.utils.ext.firstVisibleItemPosition
+import org.koitharu.kotatsu.utils.ext.isAnimationsEnabled
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import javax.inject.Inject
@@ -24,6 +27,9 @@ class WebtoonReaderFragment : BaseReader() {
@Inject
lateinit var networkState: NetworkState
+ @Inject
+ lateinit var pageLoader: PageLoader
+
private val scrollInterpolator = AccelerateDecelerateInterpolator()
private var webtoonAdapter: WebtoonAdapter? = null
@@ -36,7 +42,7 @@ class WebtoonReaderFragment : BaseReader() {
super.onViewCreated(view, savedInstanceState)
webtoonAdapter = WebtoonAdapter(
lifecycleOwner = viewLifecycleOwner,
- loader = viewModel.pageLoader,
+ loader = pageLoader,
settings = viewModel.readerSettings,
networkState = networkState,
exceptionResolver = exceptionResolver,
@@ -58,13 +64,13 @@ class WebtoonReaderFragment : BaseReader() {
}
override fun onPagesChanged(pages: List, pendingState: ReaderState?) {
- viewLifecycleScope.launchWhenCreated {
+ viewLifecycleScope.launch {
val setItems = async { webtoonAdapter?.setItems(pages) }
if (pendingState != null) {
val position = pages.indexOfFirst {
it.chapterId == pendingState.chapterId && it.index == pendingState.page
}
- setItems.await() ?: return@launchWhenCreated
+ setItems.await() ?: return@launch
if (position != -1) {
with(binding.recyclerView) {
firstVisibleItemPosition = position
@@ -98,17 +104,24 @@ class WebtoonReaderFragment : BaseReader() {
}
override fun switchPageBy(delta: Int) {
- binding.recyclerView.smoothScrollBy(
- 0,
- (binding.recyclerView.height * 0.9).toInt() * delta,
- scrollInterpolator,
- )
+ with(binding.recyclerView) {
+ if (context.isAnimationsEnabled) {
+ smoothScrollBy(0, (height * 0.9).toInt() * delta, scrollInterpolator)
+ } else {
+ nestedScrollBy(0, (height * 0.9).toInt() * delta)
+ }
+ }
}
override fun switchPageTo(position: Int, smooth: Boolean) {
binding.recyclerView.firstVisibleItemPosition = position
}
+ override fun scrollBy(delta: Int): Boolean {
+ binding.recyclerView.nestedScrollBy(0, delta)
+ return true
+ }
+
private inner class PageScrollListener : WebtoonRecyclerView.OnPageScrollListener() {
override fun onPageChanged(recyclerView: WebtoonRecyclerView, index: Int) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt
index fe9122989..0d9ca8b22 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt
@@ -8,8 +8,6 @@ import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.GridLayoutManager
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
-import javax.inject.Inject
-import javax.inject.Provider
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
@@ -22,11 +20,11 @@ import org.koitharu.kotatsu.databinding.SheetPagesBinding
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
-import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter
import org.koitharu.kotatsu.utils.ext.getParcelableCompat
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import org.koitharu.kotatsu.utils.ext.withArgs
+import javax.inject.Inject
@AndroidEntryPoint
class PagesThumbnailsSheet :
@@ -38,7 +36,7 @@ class PagesThumbnailsSheet :
lateinit var mangaRepositoryFactory: MangaRepository.Factory
@Inject
- lateinit var pageLoaderProvider: Provider
+ lateinit var pageLoader: PageLoader
@Inject
lateinit var coil: ImageLoader
@@ -49,7 +47,6 @@ class PagesThumbnailsSheet :
private lateinit var thumbnails: List
private var spanResolver: MangaListSpanResolver? = null
private var currentPageIndex = -1
- private var pageLoader: PageLoader? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -77,11 +74,10 @@ class PagesThumbnailsSheet :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- val title = arguments?.getString(ARG_TITLE)
spanResolver = MangaListSpanResolver(view.resources)
with(binding.headerBar) {
- toolbar.title = title
- toolbar.subtitle = null
+ title = arguments?.getString(ARG_TITLE)
+ subtitle = null
addOnExpansionChangeListener(this@PagesThumbnailsSheet)
}
@@ -93,7 +89,7 @@ class PagesThumbnailsSheet :
dataSet = thumbnails,
coil = coil,
scope = viewLifecycleScope,
- loader = getPageLoader(),
+ loader = pageLoader,
clickListener = this@PagesThumbnailsSheet,
)
addOnLayoutChangeListener(spanResolver)
@@ -108,8 +104,6 @@ class PagesThumbnailsSheet :
override fun onDestroyView() {
super.onDestroyView()
spanResolver = null
- pageLoader?.close()
- pageLoader = null
}
override fun onItemClick(item: MangaPage, view: View) {
@@ -117,28 +111,23 @@ class PagesThumbnailsSheet :
(parentFragment as? OnPageSelectListener)
?: (activity as? OnPageSelectListener)
)?.run {
- onPageSelected(item)
- dismiss()
- }
+ onPageSelected(item)
+ dismiss()
+ }
}
override fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean) {
if (isExpanded) {
- headerBar.toolbar.subtitle = resources.getQuantityString(
+ headerBar.subtitle = resources.getQuantityString(
R.plurals.pages,
thumbnails.size,
thumbnails.size,
)
} else {
- headerBar.toolbar.subtitle = null
+ headerBar.subtitle = null
}
}
- private fun getPageLoader(): PageLoader {
- val viewModel = (activity as? ReaderActivity)?.viewModel
- return viewModel?.pageLoader ?: pageLoaderProvider.get().also { pageLoader = it }
- }
-
companion object {
private const val ARG_PAGES = "pages"
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt
index 96534ea8a..7d2c6c3bd 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt
@@ -57,7 +57,7 @@ fun pageThumbnailAD(
ImageRequest.Builder(context)
.data(file)
.size(thumbSize)
- .decodeRegion()
+ .decodeRegion(0)
.allowRgb565(isLowRamDevice(context))
.build(),
).drawable
diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt
index 2779c5d33..e597c90a5 100644
--- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt
@@ -8,8 +8,8 @@ import android.view.View
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.SearchView
import androidx.core.view.MenuProvider
+import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
-import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.ListSelectionController
import org.koitharu.kotatsu.list.ui.MangaListFragment
@@ -19,21 +19,12 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.utils.ext.addMenuProvider
-import org.koitharu.kotatsu.utils.ext.assistedViewModels
-import org.koitharu.kotatsu.utils.ext.serializableArgument
import org.koitharu.kotatsu.utils.ext.withArgs
@AndroidEntryPoint
class RemoteListFragment : MangaListFragment() {
- @Inject
- lateinit var viewModelFactory: RemoteListViewModel.Factory
-
- public override val viewModel by assistedViewModels {
- viewModelFactory.create(source)
- }
-
- private val source by serializableArgument(ARG_SOURCE)
+ public override val viewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -74,13 +65,15 @@ class RemoteListFragment : MangaListFragment() {
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_source_settings -> {
- startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), source))
+ startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), viewModel.source))
true
}
+
R.id.action_filter -> {
onFilterClick(null)
true
}
+
else -> false
}
@@ -90,7 +83,7 @@ class RemoteListFragment : MangaListFragment() {
}
val intent = SearchActivity.newIntent(
context = this@RemoteListFragment.context ?: return false,
- source = source,
+ source = viewModel.source,
query = query,
)
startActivity(intent)
@@ -113,7 +106,7 @@ class RemoteListFragment : MangaListFragment() {
companion object {
- private const val ARG_SOURCE = "provider"
+ const val ARG_SOURCE = "provider"
fun newInstance(provider: MangaSource) = RemoteListFragment().withArgs(1) {
putSerializable(ARG_SOURCE, provider)
diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt
index aef575033..1e23e2f02 100644
--- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt
@@ -1,10 +1,9 @@
package org.koitharu.kotatsu.remotelist.ui
import androidx.lifecycle.LiveData
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -19,6 +18,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.parser.MangaRepository
+import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.list.ui.MangaListViewModel
@@ -37,20 +37,25 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
-import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
+import org.koitharu.kotatsu.utils.ext.require
import java.util.LinkedList
+import javax.inject.Inject
private const val FILTER_MIN_INTERVAL = 250L
-class RemoteListViewModel @AssistedInject constructor(
- @Assisted source: MangaSource,
+@HiltViewModel
+class RemoteListViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory,
private val searchRepository: MangaSearchRepository,
settings: AppSettings,
dataRepository: MangaDataRepository,
+ private val tagHighlighter: MangaTagHighlighter,
) : MangaListViewModel(settings), OnFilterChangedListener {
+ val source = savedStateHandle.require(RemoteListFragment.ARG_SOURCE)
private val repository = mangaRepositoryFactory.create(source) as RemoteMangaRepository
private val filter = FilterCoordinator(repository, dataRepository, viewModelScope)
private val mangaList = MutableStateFlow?>(null)
@@ -75,7 +80,7 @@ class RemoteListViewModel @AssistedInject constructor(
list == null -> add(LoadingState)
list.isEmpty() -> add(createEmptyState(header.hasSelectedTags))
else -> {
- list.toUi(this, mode)
+ list.toUi(this, mode, tagHighlighter)
when {
error != null -> add(error.toErrorFooter())
hasNext -> add(LoadingFooter)
@@ -83,7 +88,7 @@ class RemoteListViewModel @AssistedInject constructor(
}
}
}
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
+ }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
init {
filter.observeState()
@@ -192,7 +197,7 @@ class RemoteListViewModel @AssistedInject constructor(
val result = LinkedList()
for (tag in tags) {
val model = ChipsView.ChipModel(
- icon = 0,
+ tint = 0,
title = tag.title,
isCheckable = true,
isChecked = selectedTags.remove(tag),
@@ -206,7 +211,7 @@ class RemoteListViewModel @AssistedInject constructor(
}
for (tag in selectedTags) {
val model = ChipsView.ChipModel(
- icon = 0,
+ tint = 0,
title = tag.title,
isCheckable = true,
isChecked = true,
@@ -216,10 +221,4 @@ class RemoteListViewModel @AssistedInject constructor(
}
return result
}
-
- @AssistedFactory
- interface Factory {
-
- fun create(source: MangaSource): RemoteListViewModel
- }
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt
index 00fa4d423..ccfcc2186 100644
--- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt
@@ -4,17 +4,18 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
+import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import coil.ImageLoader
import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration
+import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.databinding.ActivityScrobblerConfigBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
@@ -22,28 +23,19 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.ui.config.adapter.ScrobblingMangaAdapter
import org.koitharu.kotatsu.tracker.ui.feed.adapter.FeedAdapter
-import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
-import org.koitharu.kotatsu.utils.ext.getDisplayMessage
-import org.koitharu.kotatsu.utils.ext.hideCompat
import org.koitharu.kotatsu.utils.ext.newImageRequest
-import org.koitharu.kotatsu.utils.ext.showCompat
import javax.inject.Inject
@AndroidEntryPoint
class ScrobblerConfigActivity : BaseActivity(),
OnListItemClickListener, View.OnClickListener {
- @Inject
- lateinit var viewModelFactory: ScrobblerConfigViewModel.Factory
-
@Inject
lateinit var coil: ImageLoader
- private val viewModel: ScrobblerConfigViewModel by assistedViewModels {
- viewModelFactory.create(requireNotNull(getScrobblerService(intent)))
- }
+ private val viewModel: ScrobblerConfigViewModel by viewModels()
private var paddingVertical = 0
private var paddingHorizontal = 0
@@ -72,7 +64,7 @@ class ScrobblerConfigActivity : BaseActivity(),
viewModel.content.observe(this, listAdapter::setItems)
viewModel.user.observe(this, this::onUserChanged)
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
- viewModel.onError.observe(this, this::onError)
+ viewModel.onError.observe(this, SnackbarErrorObserver(binding.recyclerView, null))
viewModel.onLoggedOut.observe(this) {
finishAfterTransition()
}
@@ -125,28 +117,20 @@ class ScrobblerConfigActivity : BaseActivity(),
return
}
binding.imageViewAvatar.isVisible = true
- binding.imageViewAvatar.newImageRequest(user.avatar, null)
+ binding.imageViewAvatar.newImageRequest(this, user.avatar)
?.enqueueWith(coil)
}
private fun onLoadingStateChanged(isLoading: Boolean) {
binding.progressBar.run {
if (isLoading) {
- showCompat()
+ show()
} else {
- hideCompat()
+ hide()
}
}
}
- private fun onError(e: Throwable) {
- Snackbar.make(
- binding.recyclerView,
- e.getDisplayMessage(resources),
- Snackbar.LENGTH_LONG,
- ).show()
- }
-
private fun showUserDialog() {
MaterialAlertDialogBuilder(this)
.setTitle(title)
@@ -159,30 +143,14 @@ class ScrobblerConfigActivity : BaseActivity(),
companion object {
- private const val EXTRA_SERVICE_ID = "service"
+ const val EXTRA_SERVICE_ID = "service"
- private const val HOST_SHIKIMORI_AUTH = "shikimori-auth"
- private const val HOST_ANILIST_AUTH = "anilist-auth"
- private const val HOST_MAL_AUTH = "mal-auth"
+ const val HOST_SHIKIMORI_AUTH = "shikimori-auth"
+ const val HOST_ANILIST_AUTH = "anilist-auth"
+ const val HOST_MAL_AUTH = "mal-auth"
fun newIntent(context: Context, service: ScrobblerService) =
Intent(context, ScrobblerConfigActivity::class.java)
.putExtra(EXTRA_SERVICE_ID, service.id)
-
- private fun getScrobblerService(
- intent: Intent
- ): ScrobblerService? {
- val serviceId = intent.getIntExtra(EXTRA_SERVICE_ID, 0)
- if (serviceId != 0) {
- return enumValues().first { it.id == serviceId }
- }
- val uri = intent.data ?: return null
- return when (uri.host) {
- HOST_SHIKIMORI_AUTH -> ScrobblerService.SHIKIMORI
- HOST_ANILIST_AUTH -> ScrobblerService.ANILIST
- HOST_MAL_AUTH -> ScrobblerService.MAL
- else -> null
- }
- }
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt
index c4c3ac324..770e38986 100644
--- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt
@@ -1,10 +1,10 @@
package org.koitharu.kotatsu.scrobbling.common.ui.config
+import android.net.Uri
import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -24,12 +25,16 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.onFirst
+import org.koitharu.kotatsu.utils.ext.require
+import javax.inject.Inject
-class ScrobblerConfigViewModel @AssistedInject constructor(
- @Assisted scrobblerService: ScrobblerService,
+@HiltViewModel
+class ScrobblerConfigViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
) : BaseViewModel() {
+ private val scrobblerService = getScrobblerService(savedStateHandle)
private val scrobbler = scrobblers.first { it.scrobblerService == scrobblerService }
val titleResId = scrobbler.scrobblerService.titleResId
@@ -90,9 +95,19 @@ class ScrobblerConfigViewModel @AssistedInject constructor(
return result
}
- @AssistedFactory
- interface Factory {
-
- fun create(service: ScrobblerService): ScrobblerConfigViewModel
+ private fun getScrobblerService(
+ savedStateHandle: SavedStateHandle,
+ ): ScrobblerService {
+ val serviceId = savedStateHandle.get(ScrobblerConfigActivity.EXTRA_SERVICE_ID) ?: 0
+ if (serviceId != 0) {
+ return enumValues().first { it.id == serviceId }
+ }
+ val uri = savedStateHandle.require(BaseActivity.EXTRA_DATA)
+ return when (uri.host) {
+ ScrobblerConfigActivity.HOST_SHIKIMORI_AUTH -> ScrobblerService.SHIKIMORI
+ ScrobblerConfigActivity.HOST_ANILIST_AUTH -> ScrobblerService.ANILIST
+ ScrobblerConfigActivity.HOST_MAL_AUTH -> ScrobblerService.MAL
+ else -> error("Wrong scrobbler uri: $uri")
+ }
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAD.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAD.kt
index fb04299f4..04c978f66 100644
--- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAD.kt
@@ -25,11 +25,10 @@ fun scrobblingMangaAD(
itemView.setOnClickListener(clickListenerAdapter)
bind {
- binding.imageViewCover.newImageRequest(item.coverUrl, null)?.run {
+ binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
- lifecycle(lifecycleOwner)
enqueueWith(coil)
}
binding.textViewTitle.text = item.title
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAdapter.kt
index 301836728..b33887675 100644
--- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAdapter.kt
@@ -20,7 +20,7 @@ class ScrobblingMangaAdapter(
init {
delegatesManager.addDelegate(scrobblingMangaAD(clickListener, coil, lifecycleOwner))
.addDelegate(scrobblingHeaderAD())
- .addDelegate(emptyStateListAD(coil, null))
+ .addDelegate(emptyStateListAD(coil, lifecycleOwner, null))
}
private class DiffCallback : DiffUtil.ItemCallback() {
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorBottomSheet.kt
index a000c8b8a..76b5d8de0 100644
--- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorBottomSheet.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorBottomSheet.kt
@@ -9,6 +9,7 @@ import android.widget.Toast
import androidx.appcompat.widget.SearchView
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.viewModels
import coil.ImageLoader
import com.google.android.material.tabs.TabLayout
import dagger.hilt.android.AndroidEntryPoint
@@ -26,10 +27,8 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter.ScrobblerMangaSelectionDecoration
import org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter.ScrobblerSelectorAdapter
-import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
-import org.koitharu.kotatsu.utils.ext.requireParcelable
import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
@@ -44,19 +43,12 @@ class ScrobblingSelectorBottomSheet :
TabLayout.OnTabSelectedListener,
ListStateHolderListener {
- @Inject
- lateinit var viewModelFactory: ScrobblingSelectorViewModel.Factory
-
@Inject
lateinit var coil: ImageLoader
private var collapsibleActionViewCallback: CollapseActionViewCallback? = null
- private val viewModel by assistedViewModels {
- viewModelFactory.create(
- requireArguments().requireParcelable(MangaIntent.KEY_MANGA).manga,
- )
- }
+ private val viewModel by viewModels()
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingSelectorBinding {
return SheetScrobblingSelectorBinding.inflate(inflater, container, false)
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt
index 318d2a2f8..7af348b48 100644
--- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt
@@ -2,35 +2,40 @@ package org.koitharu.kotatsu.scrobbling.common.ui.selector
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.RecyclerView.NO_ID
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseViewModel
+import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
-import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint
import org.koitharu.kotatsu.utils.SingleLiveEvent
-import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
+import org.koitharu.kotatsu.utils.ext.require
import org.koitharu.kotatsu.utils.ext.requireValue
+import javax.inject.Inject
-class ScrobblingSelectorViewModel @AssistedInject constructor(
- @Assisted val manga: Manga,
+@HiltViewModel
+class ScrobblingSelectorViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
) : BaseViewModel() {
+ val manga = savedStateHandle.require(MangaIntent.KEY_MANGA).manga
+
val availableScrobblers = scrobblers.filter { it.isAvailable }
val selectedScrobblerIndex = MutableLiveData(0)
@@ -65,7 +70,7 @@ class ScrobblingSelectorViewModel @AssistedInject constructor(
},
)
}
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
+ }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
val selectedItemId = MutableLiveData(NO_ID)
val searchQuery = MutableLiveData(manga.title)
@@ -172,10 +177,4 @@ class ScrobblingSelectorViewModel @AssistedInject constructor(
textSecondary = 0,
actionStringRes = R.string.try_again,
)
-
- @AssistedFactory
- interface Factory {
-
- fun create(manga: Manga): ScrobblingSelectorViewModel
- }
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblingMangaAD.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblingMangaAD.kt
index d56427fbd..79487a484 100644
--- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblingMangaAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblingMangaAD.kt
@@ -27,12 +27,11 @@ fun scrobblingMangaAD(
bind {
binding.textViewTitle.text = item.name
binding.textViewSubtitle.textAndVisible = item.altName
- binding.imageViewCover.newImageRequest(item.cover)?.run {
+ binding.imageViewCover.newImageRequest(lifecycleOwner, item.cover)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
- lifecycle(lifecycleOwner)
enqueueWith(coil)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt b/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt
index 140ed3917..6ab02557c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt
@@ -22,7 +22,9 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
+import javax.inject.Singleton
+@Singleton
class MangaSearchRepository @Inject constructor(
private val settings: AppSettings,
private val db: MangaDatabase,
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaListActivity.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaListActivity.kt
index 08891d4ba..c1063de8c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaListActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaListActivity.kt
@@ -18,7 +18,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
-import kotlin.text.Typography.dagger
+import org.koitharu.kotatsu.utils.ext.getSerializableExtraCompat
@AndroidEntryPoint
class MangaListActivity :
@@ -33,7 +33,7 @@ class MangaListActivity :
setContentView(ActivityContainerBinding.inflate(layoutInflater))
val tags = intent.getParcelableExtraCompat(EXTRA_TAGS)?.tags
supportActionBar?.setDisplayHomeAsUpEnabled(true)
- val source = intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: tags?.firstOrNull()?.source
+ val source = intent.getSerializableExtraCompat(EXTRA_SOURCE) ?: tags?.firstOrNull()?.source
if (source == null) {
finishAfterTransition()
return
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt
index 6d14265be..f86f82b3e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt
@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivitySearchBinding
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
+import org.koitharu.kotatsu.utils.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.utils.ext.showKeyboard
@AndroidEntryPoint
@@ -26,7 +27,7 @@ class SearchActivity : BaseActivity(), SearchView.OnQuery
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivitySearchBinding.inflate(layoutInflater))
- source = intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: run {
+ source = intent.getSerializableExtraCompat(EXTRA_SOURCE) ?: run {
finishAfterTransition()
return
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt
index 6e83fac82..b5fa580fe 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt
@@ -2,29 +2,18 @@ package org.koitharu.kotatsu.search.ui
import android.view.Menu
import androidx.appcompat.view.ActionMode
+import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
-import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.ListSelectionController
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource
-import org.koitharu.kotatsu.utils.ext.assistedViewModels
-import org.koitharu.kotatsu.utils.ext.serializableArgument
-import org.koitharu.kotatsu.utils.ext.stringArgument
import org.koitharu.kotatsu.utils.ext.withArgs
@AndroidEntryPoint
class SearchFragment : MangaListFragment() {
- @Inject
- lateinit var viewModelFactory: SearchViewModel.Factory
-
- override val viewModel by assistedViewModels {
- viewModelFactory.create(source, query.orEmpty())
- }
-
- private val query by stringArgument(ARG_QUERY)
- private val source by serializableArgument(ARG_SOURCE)
+ override val viewModel by viewModels()
override fun onScrolledToEnd() {
viewModel.loadNextPage()
@@ -37,8 +26,8 @@ class SearchFragment : MangaListFragment() {
companion object {
- private const val ARG_QUERY = "query"
- private const val ARG_SOURCE = "source"
+ const val ARG_QUERY = "query"
+ const val ARG_SOURCE = "source"
fun newInstance(source: MangaSource, query: String) = SearchFragment().withArgs(2) {
putSerializable(ARG_SOURCE, source)
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt
index b959f65a7..08e6db9c3 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt
@@ -1,9 +1,8 @@
package org.koitharu.kotatsu.search.ui
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -11,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaRepository
+import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
@@ -21,17 +21,20 @@ import org.koitharu.kotatsu.list.ui.model.toErrorFooter
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga
-import org.koitharu.kotatsu.parsers.model.MangaSource
-import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+import org.koitharu.kotatsu.utils.asFlowLiveData
+import org.koitharu.kotatsu.utils.ext.require
+import javax.inject.Inject
-class SearchViewModel @AssistedInject constructor(
- @Assisted source: MangaSource,
- @Assisted private val query: String,
+@HiltViewModel
+class SearchViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
repositoryFactory: MangaRepository.Factory,
settings: AppSettings,
+ private val tagHighlighter: MangaTagHighlighter,
) : MangaListViewModel(settings) {
- private val repository = repositoryFactory.create(source)
+ private val query = savedStateHandle.require(SearchFragment.ARG_QUERY)
+ private val repository = repositoryFactory.create(savedStateHandle.require(SearchFragment.ARG_SOURCE))
private val mangaList = MutableStateFlow?>(null)
private val hasNextPage = MutableStateFlow(false)
private val listError = MutableStateFlow(null)
@@ -57,7 +60,7 @@ class SearchViewModel @AssistedInject constructor(
else -> {
val result = ArrayList(list.size + 1)
- list.toUi(result, mode)
+ list.toUi(result, mode, tagHighlighter)
when {
error != null -> result += error.toErrorFooter()
hasNext -> result += LoadingFooter
@@ -65,7 +68,7 @@ class SearchViewModel @AssistedInject constructor(
result
}
}
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
+ }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
init {
loadList(append = false)
@@ -109,10 +112,4 @@ class SearchViewModel @AssistedInject constructor(
}
}
}
-
- @AssistedFactory
- interface Factory {
-
- fun create(source: MangaSource, query: String): SearchViewModel
- }
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt
index 1e762e3ef..63413999a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt
@@ -6,6 +6,7 @@ import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
+import androidx.activity.viewModels
import androidx.appcompat.view.ActionMode
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
@@ -31,7 +32,6 @@ import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.search.ui.multi.adapter.MultiSearchAdapter
import org.koitharu.kotatsu.utils.ShareHelper
-import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.invalidateNestedItemDecorations
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import javax.inject.Inject
@@ -40,17 +40,12 @@ import javax.inject.Inject
class MultiSearchActivity :
BaseActivity(),
MangaListListener,
- ListSelectionController.Callback {
-
- @Inject
- lateinit var viewModelFactory: MultiSearchViewModel.Factory
+ ListSelectionController.Callback2 {
@Inject
lateinit var coil: ImageLoader
- private val viewModel by assistedViewModels {
- viewModelFactory.create(intent.getStringExtra(EXTRA_QUERY).orEmpty())
- }
+ private val viewModel by viewModels()
private lateinit var adapter: MultiSearchAdapter
private lateinit var selectionController: ListSelectionController
@@ -139,17 +134,16 @@ class MultiSearchActivity :
override fun onListHeaderClick(item: ListHeader, view: View) = Unit
- override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
- mode.menuInflater.inflate(R.menu.mode_remote, menu)
- return true
+ override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
+ binding.recyclerView.invalidateNestedItemDecorations()
}
- override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
- mode.title = selectionController.count.toString()
+ override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
+ mode.menuInflater.inflate(R.menu.mode_remote, menu)
return true
}
- override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
+ override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_share -> {
ShareHelper(this).shareMangaLinks(collectSelectedItems())
@@ -173,17 +167,13 @@ class MultiSearchActivity :
}
}
- override fun onSelectionChanged(count: Int) {
- binding.recyclerView.invalidateNestedItemDecorations()
- }
-
private fun collectSelectedItems(): Set {
return viewModel.getItems(selectionController.peekCheckedIds())
}
companion object {
- private const val EXTRA_QUERY = "query"
+ const val EXTRA_QUERY = "query"
fun newIntent(context: Context, query: String) =
Intent(context, MultiSearchActivity::class.java)
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt
index 2a9455e08..fad26f82f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt
@@ -2,11 +2,15 @@ package org.koitharu.kotatsu.search.ui.multi
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
-import kotlinx.coroutines.*
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
@@ -16,17 +20,24 @@ import org.koitharu.kotatsu.core.exceptions.CompositeException
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
-import org.koitharu.kotatsu.list.ui.model.*
+import org.koitharu.kotatsu.list.ui.model.EmptyState
+import org.koitharu.kotatsu.list.ui.model.ListModel
+import org.koitharu.kotatsu.list.ui.model.LoadingFooter
+import org.koitharu.kotatsu.list.ui.model.LoadingState
+import org.koitharu.kotatsu.list.ui.model.toErrorState
+import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga
-import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
+import javax.inject.Inject
private const val MAX_PARALLELISM = 4
private const val MIN_HAS_MORE_ITEMS = 8
-class MultiSearchViewModel @AssistedInject constructor(
- @Assisted initialQuery: String,
+@HiltViewModel
+class MultiSearchViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory,
) : BaseViewModel() {
@@ -36,7 +47,7 @@ class MultiSearchViewModel @AssistedInject constructor(
private val loadingData = MutableStateFlow(false)
private var listError = MutableStateFlow(null)
- val query = MutableLiveData(initialQuery)
+ val query = MutableLiveData(savedStateHandle.get(MultiSearchActivity.EXTRA_QUERY).orEmpty())
val list: LiveData> = combine(
listData,
loadingData,
@@ -59,10 +70,10 @@ class MultiSearchViewModel @AssistedInject constructor(
loading -> list + LoadingFooter
else -> list
}
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
+ }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
init {
- doSearch(initialQuery)
+ doSearch(query.value.orEmpty())
}
fun getItems(ids: Set): Set {
@@ -104,7 +115,7 @@ class MultiSearchViewModel @AssistedInject constructor(
async(dispatcher) {
runCatchingCancellable {
val list = mangaRepositoryFactory.create(source).getList(offset = 0, query = q)
- .toUi(ListMode.GRID)
+ .toUi(ListMode.GRID, null)
if (list.isNotEmpty()) {
MultiSearchListModel(source, list.size > MIN_HAS_MORE_ITEMS, list)
} else {
@@ -135,10 +146,4 @@ class MultiSearchViewModel @AssistedInject constructor(
}
}
}
-
- @AssistedFactory
- interface Factory {
-
- fun create(initialQuery: String): MultiSearchViewModel
- }
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt
index d483ed4db..179957661 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt
@@ -5,13 +5,17 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView.RecycledViewPool
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
-import kotlin.jvm.internal.Intrinsics
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.list.ui.ItemSizeResolver
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
-import org.koitharu.kotatsu.list.ui.adapter.*
+import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
+import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
+import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
+import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
+import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.search.ui.multi.MultiSearchListModel
+import kotlin.jvm.internal.Intrinsics
class MultiSearchAdapter(
lifecycleOwner: LifecycleOwner,
@@ -38,7 +42,7 @@ class MultiSearchAdapter(
)
.addDelegate(loadingStateAD())
.addDelegate(loadingFooterAD())
- .addDelegate(emptyStateListAD(coil, listener))
+ .addDelegate(emptyStateListAD(coil, lifecycleOwner, listener))
.addDelegate(errorStateListAD(listener))
}
@@ -49,6 +53,7 @@ class MultiSearchAdapter(
oldItem is MultiSearchListModel && newItem is MultiSearchListModel -> {
oldItem.source == newItem.source
}
+
else -> oldItem.javaClass == newItem.javaClass
}
}
@@ -57,4 +62,4 @@ class MultiSearchAdapter(
return Intrinsics.areEqual(oldItem, newItem)
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt
index 31edd3481..361969911 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt
@@ -134,7 +134,7 @@ class SearchSuggestionViewModel @Inject constructor(
private fun mapTags(tags: List): List = tags.map { tag ->
ChipsView.ChipModel(
- icon = 0,
+ tint = 0,
title = tag.title,
data = tag,
isCheckable = false,
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceAD.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceAD.kt
index 086d52f39..5d713a300 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceAD.kt
@@ -10,6 +10,7 @@ import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
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.source
import org.koitharu.kotatsu.utils.image.FaviconFallbackDrawable
fun searchSuggestionSourceAD(
@@ -31,11 +32,11 @@ fun searchSuggestionSourceAD(
binding.textViewTitle.text = item.source.title
binding.switchLocal.isChecked = item.isEnabled
val fallbackIcon = FaviconFallbackDrawable(context, item.source.name)
- binding.imageViewCover.newImageRequest(item.source.faviconUri(), item.source)?.run {
+ binding.imageViewCover.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
fallback(fallbackIcon)
placeholder(fallbackIcon)
error(fallbackIcon)
- lifecycle(lifecycleOwner)
+ source(item.source)
enqueueWith(coil)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt
index b451a7997..992ebfd9b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt
@@ -18,6 +18,7 @@ import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback
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.source
fun searchSuggestionMangaListAD(
coil: ImageLoader,
@@ -55,12 +56,12 @@ private fun searchSuggestionMangaGridAD(
}
bind {
- binding.imageViewCover.newImageRequest(item.coverUrl, item.source)?.run {
+ binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
- lifecycle(lifecycleOwner)
+ source(item.source)
enqueueWith(coil)
}
binding.textViewTitle.text = item.title
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt
index cc0cf7cf4..c0b53620a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.settings
import android.os.Bundle
import android.view.View
+import androidx.lifecycle.Lifecycle
import androidx.preference.Preference
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
@@ -18,6 +19,7 @@ import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.FileSize
+import org.koitharu.kotatsu.utils.ext.awaitStateAtLeast
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import javax.inject.Inject
@@ -51,17 +53,17 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
findPreference(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindSummaryToCacheSize(CacheDir.PAGES)
findPreference(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindSummaryToCacheSize(CacheDir.THUMBS)
findPreference(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref ->
- viewLifecycleScope.launchWhenResumed {
+ viewLifecycleScope.launch {
+ lifecycle.awaitStateAtLeast(Lifecycle.State.RESUMED)
val items = searchRepository.getSearchHistoryCount()
- pref.summary =
- pref.context.resources.getQuantityString(R.plurals.items, items, items)
+ pref.summary = pref.context.resources.getQuantityString(R.plurals.items, items, items)
}
}
findPreference(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref ->
- viewLifecycleScope.launchWhenResumed {
+ viewLifecycleScope.launch {
+ lifecycle.awaitStateAtLeast(Lifecycle.State.RESUMED)
val items = trackerRepo.getLogsCount()
- pref.summary =
- pref.context.resources.getQuantityString(R.plurals.items, items, items)
+ pref.summary = pref.context.resources.getQuantityString(R.plurals.items, items, items)
}
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt
index eadf48103..ad800fcd4 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt
@@ -23,8 +23,10 @@ import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.databinding.ActivitySettingsBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.model.MangaSource
-import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment
+import org.koitharu.kotatsu.settings.about.AboutSettingsFragment
+import org.koitharu.kotatsu.settings.sources.SourcesListFragment
import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment
+import org.koitharu.kotatsu.utils.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.utils.ext.isScrolledToTop
@AndroidEntryPoint
@@ -88,6 +90,7 @@ class SettingsActivity :
}
}
+ @Suppress("DEPRECATION")
override fun onPreferenceStartFragment(
caller: PreferenceFragmentCompat,
pref: Preference,
@@ -127,10 +130,17 @@ class SettingsActivity :
ACTION_HISTORY -> HistorySettingsFragment()
ACTION_TRACKER -> TrackerSettingsFragment()
ACTION_SOURCE -> SourceSettingsFragment.newInstance(
- intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL,
+ intent.getSerializableExtraCompat(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL,
)
- ACTION_MANAGE_SOURCES -> SourcesSettingsFragment()
+ ACTION_MANAGE_SOURCES -> SourcesListFragment()
+ Intent.ACTION_VIEW -> {
+ when (intent.data?.host) {
+ HOST_ABOUT -> AboutSettingsFragment()
+ else -> SettingsHeadersFragment()
+ }
+ }
+
else -> SettingsHeadersFragment()
}
supportFragmentManager.commit {
@@ -146,9 +156,9 @@ class SettingsActivity :
private const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER"
private const val ACTION_HISTORY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_HISTORY"
private const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
- private const val ACTION_SHIKIMORI = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SHIKIMORI_SETTINGS"
private const val ACTION_MANAGE_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES_LIST"
private const val EXTRA_SOURCE = "source"
+ private const val HOST_ABOUT = "about"
fun newIntent(context: Context) = Intent(context, SettingsActivity::class.java)
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt
index dbad1e134..43327d44a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt
@@ -22,8 +22,8 @@ import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import org.koitharu.kotatsu.utils.ext.awaitViewLifecycle
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
+import org.koitharu.kotatsu.utils.ext.requireSerializable
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
-import org.koitharu.kotatsu.utils.ext.serializableArgument
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
@@ -34,10 +34,16 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
- private val source by serializableArgument(EXTRA_SOURCE)
+ private lateinit var source: MangaSource
private var repository: RemoteMangaRepository? = null
private val exceptionResolver = ExceptionResolver(this)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ source = requireArguments().requireSerializable(EXTRA_SOURCE)
+ repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository
+ super.onCreate(savedInstanceState)
+ }
+
override fun onResume() {
super.onResume()
setTitle(source.title)
@@ -45,8 +51,7 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
preferenceManager.sharedPreferencesName = source.name
- val repo = mangaRepositoryFactory.create(source) as? RemoteMangaRepository ?: return
- repository = repo
+ val repo = repository ?: return
addPreferencesFromResource(R.xml.pref_source)
addPreferencesFromRepository(repo)
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt
index c53fcfdce..cfede729c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt
@@ -36,6 +36,7 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
}
findPreference(AppSettings.KEY_UPDATES_UNSTABLE)?.run {
isEnabled = VersionId(BuildConfig.VERSION_NAME).isStable
+ if (!isEnabled) isChecked = true
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt
index 68edfe874..84341d278 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt
@@ -6,28 +6,21 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
+import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
-import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.backup.CompositeResult
import org.koitharu.kotatsu.databinding.DialogProgressBinding
-import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
-import org.koitharu.kotatsu.utils.ext.toUriOrNull
import org.koitharu.kotatsu.utils.ext.withArgs
import org.koitharu.kotatsu.utils.progress.Progress
@AndroidEntryPoint
class RestoreDialogFragment : AlertDialogFragment() {
- @Inject
- lateinit var viewModelFactory: RestoreViewModel.Factory
-
- private val viewModel by assistedViewModels {
- viewModelFactory.create(arguments?.getString(ARG_FILE)?.toUriOrNull())
- }
+ private val viewModel: RestoreViewModel by viewModels()
override fun onInflateView(
inflater: LayoutInflater,
@@ -74,12 +67,14 @@ class RestoreDialogFragment : AlertDialogFragment() {
when {
result.isAllSuccess -> builder.setTitle(R.string.data_restored)
.setMessage(R.string.data_restored_success)
+
result.isAllFailed -> builder.setTitle(R.string.error)
.setMessage(
result.failures.map {
it.getDisplayMessage(resources)
}.distinct().joinToString("\n"),
)
+
else -> builder.setTitle(R.string.data_restored)
.setMessage(R.string.data_restored_with_errors)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt
index 01631986f..ffb29d8f9 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt
@@ -1,15 +1,10 @@
package org.koitharu.kotatsu.settings.backup
import android.content.Context
-import android.net.Uri
import androidx.lifecycle.MutableLiveData
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
+import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
-import java.io.File
-import java.io.FileNotFoundException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.base.ui.BaseViewModel
@@ -18,10 +13,15 @@ import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.backup.BackupZipInput
import org.koitharu.kotatsu.core.backup.CompositeResult
import org.koitharu.kotatsu.utils.SingleLiveEvent
+import org.koitharu.kotatsu.utils.ext.toUriOrNull
import org.koitharu.kotatsu.utils.progress.Progress
+import java.io.File
+import java.io.FileNotFoundException
+import javax.inject.Inject
-class RestoreViewModel @AssistedInject constructor(
- @Assisted uri: Uri?,
+@HiltViewModel
+class RestoreViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
private val repository: BackupRepository,
@ApplicationContext context: Context,
) : BaseViewModel() {
@@ -31,9 +31,8 @@ class RestoreViewModel @AssistedInject constructor(
init {
launchLoadingJob {
- if (uri == null) {
- throw FileNotFoundException()
- }
+ val uri = savedStateHandle.get(RestoreDialogFragment.ARG_FILE)
+ ?.toUriOrNull() ?: throw FileNotFoundException()
val contentResolver = context.contentResolver
val backup = runInterruptible(Dispatchers.IO) {
@@ -65,10 +64,4 @@ class RestoreViewModel @AssistedInject constructor(
}
}
}
-
- @AssistedFactory
- interface Factory {
-
- fun create(uri: Uri?): RestoreViewModel
- }
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt
index ac855f1e1..7c76e4d84 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt
@@ -7,17 +7,15 @@ import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
-import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
-import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.databinding.DialogOnboardBinding
-import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
+import javax.inject.Inject
@AndroidEntryPoint
class NewSourcesDialogFragment :
@@ -36,7 +34,7 @@ class NewSourcesDialogFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- val adapter = SourceConfigAdapter(this, coil, viewLifecycleOwner)
+ val adapter = SourcesSelectAdapter(this, coil, viewLifecycleOwner)
binding.recyclerView.adapter = adapter
binding.textViewTitle.setText(R.string.new_sources_text)
@@ -61,10 +59,10 @@ class NewSourcesDialogFragment :
viewModel.onItemEnabledChanged(item, isEnabled)
}
- override fun onDragHandleTouch(holder: RecyclerView.ViewHolder) = Unit
-
override fun onHeaderClick(header: SourceConfigItem.LocaleGroup) = Unit
+ override fun onCloseTip(tip: SourceConfigItem.Tip) = Unit
+
companion object {
private const val TAG = "NewSources"
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/newsources/SourcesSelectAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/settings/newsources/SourcesSelectAdapter.kt
new file mode 100644
index 000000000..17b7aaf0d
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/newsources/SourcesSelectAdapter.kt
@@ -0,0 +1,18 @@
+package org.koitharu.kotatsu.settings.newsources
+
+import androidx.lifecycle.LifecycleOwner
+import coil.ImageLoader
+import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
+import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigDiffCallback
+import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
+import org.koitharu.kotatsu.settings.sources.adapter.sourceConfigItemCheckableDelegate
+import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
+
+class SourcesSelectAdapter(
+ listener: SourceConfigListener,
+ coil: ImageLoader,
+ lifecycleOwner: LifecycleOwner,
+) : AsyncListDifferDelegationAdapter(
+ SourceConfigDiffCallback(),
+ sourceConfigItemCheckableDelegate(listener, coil, lifecycleOwner),
+)
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupViewModel.kt
index b07860bbe..351ccc360 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupViewModel.kt
@@ -2,14 +2,14 @@ package org.koitharu.kotatsu.settings.protect
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.md5
import org.koitharu.kotatsu.utils.SingleLiveEvent
-import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+import org.koitharu.kotatsu.utils.asFlowLiveData
+import javax.inject.Inject
@HiltViewModel
class ProtectSetupViewModel @Inject constructor(
@@ -20,7 +20,7 @@ class ProtectSetupViewModel @Inject constructor(
val isSecondStep = firstPassword.map {
it != null
- }.asLiveDataDistinct(viewModelScope.coroutineContext, false)
+ }.asFlowLiveData(viewModelScope.coroutineContext, false)
val onPasswordSet = SingleLiveEvent()
val onPasswordMismatch = SingleLiveEvent()
val onClearText = SingleLiveEvent()
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesListFragment.kt
similarity index 78%
rename from app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt
rename to app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesListFragment.kt
index e1962a000..2fdd8b92f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesListFragment.kt
@@ -1,7 +1,12 @@
package org.koitharu.kotatsu.settings.sources
import android.os.Bundle
-import android.view.*
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.core.graphics.Insets
import androidx.core.view.MenuProvider
@@ -11,10 +16,10 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
-import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
+import org.koitharu.kotatsu.base.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.settings.SettingsActivity
@@ -24,9 +29,11 @@ import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
import org.koitharu.kotatsu.utils.ext.addMenuProvider
+import org.koitharu.kotatsu.utils.ext.getItem
+import javax.inject.Inject
@AndroidEntryPoint
-class SourcesSettingsFragment :
+class SourcesListFragment :
BaseFragment(),
SourceConfigListener,
RecyclerViewOwner {
@@ -35,7 +42,7 @@ class SourcesSettingsFragment :
lateinit var coil: ImageLoader
private var reorderHelper: ItemTouchHelper? = null
- private val viewModel by viewModels()
+ private val viewModel by viewModels()
override val recyclerView: RecyclerView
get() = binding.recyclerView
@@ -63,6 +70,7 @@ class SourcesSettingsFragment :
viewModel.items.observe(viewLifecycleOwner) {
sourcesAdapter.items = it
}
+ viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
addMenuProvider(SourcesMenuProvider())
}
@@ -89,14 +97,14 @@ class SourcesSettingsFragment :
viewModel.setEnabled(item.source, isEnabled)
}
- override fun onDragHandleTouch(holder: RecyclerView.ViewHolder) {
- reorderHelper?.startDrag(holder)
- }
-
override fun onHeaderClick(header: SourceConfigItem.LocaleGroup) {
viewModel.expandOrCollapse(header.localeId)
}
+ override fun onCloseTip(tip: SourceConfigItem.Tip) {
+ viewModel.onTipClosed(tip)
+ }
+
private inner class SourcesMenuProvider :
MenuProvider,
MenuItem.OnActionExpandListener,
@@ -117,6 +125,7 @@ class SourcesSettingsFragment :
viewModel.disableAll()
true
}
+
else -> false
}
@@ -140,7 +149,7 @@ class SourcesSettingsFragment :
private inner class SourcesReorderCallback : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.DOWN or ItemTouchHelper.UP,
- 0,
+ ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT,
) {
override fun onMove(
@@ -161,8 +170,31 @@ class SourcesSettingsFragment :
target.bindingAdapterPosition,
)
- override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
+ override fun getDragDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
+ val item = viewHolder.getItem(SourceConfigItem.SourceItem::class.java)
+ return if (item != null && item.isDraggable) {
+ super.getDragDirs(recyclerView, viewHolder)
+ } else {
+ 0
+ }
+ }
+
+ override fun getSwipeDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
+ val item = viewHolder.getItem(SourceConfigItem.Tip::class.java)
+ return if (item != null) {
+ super.getSwipeDirs(recyclerView, viewHolder)
+ } else {
+ 0
+ }
+ }
+
+ override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
+ val item = viewHolder.getItem(SourceConfigItem.Tip::class.java)
+ if (item != null) {
+ viewModel.onTipClosed(item)
+ }
+ }
- override fun isLongPressDragEnabled() = false
+ override fun isLongPressDragEnabled() = true
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesListViewModel.kt
similarity index 57%
rename from app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt
rename to app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesListViewModel.kt
index 2ff8a9ff8..22b7a6c79 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesListViewModel.kt
@@ -3,43 +3,62 @@ package org.koitharu.kotatsu.settings.sources
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.MutableLiveData
import dagger.hilt.android.lifecycle.HiltViewModel
-import java.util.*
-import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runInterruptible
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.ui.BaseViewModel
+import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.model.getLocaleTitle
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
+import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.map
import org.koitharu.kotatsu.utils.ext.move
+import java.util.Locale
+import java.util.TreeMap
+import javax.inject.Inject
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
private const val KEY_ENABLED = "!"
+private const val TIP_REORDER = "src_reorder"
@HiltViewModel
-class SourcesSettingsViewModel @Inject constructor(
+class SourcesListViewModel @Inject constructor(
private val settings: AppSettings,
) : BaseViewModel() {
val items = MutableLiveData>(emptyList())
+ val onActionDone = SingleLiveEvent()
+ private val mutex = Mutex()
+
private val expandedGroups = HashSet()
private var searchQuery: String? = null
init {
- buildList()
+ launchAtomicJob(Dispatchers.Default) {
+ buildList()
+ }
}
fun reorderSources(oldPos: Int, newPos: Int): Boolean {
val snapshot = items.value?.toMutableList() ?: return false
if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
- snapshot.move(oldPos, newPos)
- settings.sourcesOrder = snapshot.mapNotNull {
- (it as? SourceConfigItem.SourceItem)?.source?.name
+ launchAtomicJob(Dispatchers.Default) {
+ snapshot.move(oldPos, newPos)
+ settings.sourcesOrder = snapshot.mapNotNull {
+ (it as? SourceConfigItem.SourceItem)?.source?.name
+ }
+ buildList()
}
- buildList()
return true
}
@@ -51,57 +70,79 @@ class SourcesSettingsViewModel @Inject constructor(
}
fun setEnabled(source: MangaSource, isEnabled: Boolean) {
- settings.hiddenSources = if (isEnabled) {
- settings.hiddenSources - source.name
- } else {
- settings.hiddenSources + source.name
- }
- if (isEnabled) {
- settings.markKnownSources(setOf(source))
+ launchAtomicJob(Dispatchers.Default) {
+ settings.hiddenSources = if (isEnabled) {
+ settings.hiddenSources - source.name
+ } else {
+ settings.hiddenSources + source.name
+ }
+ if (isEnabled) {
+ settings.markKnownSources(setOf(source))
+ } else {
+ val rollback = ReversibleHandle {
+ setEnabled(source, true)
+ }
+ onActionDone.postCall(ReversibleAction(R.string.source_disabled, rollback))
+ }
+ buildList()
}
- buildList()
}
fun disableAll() {
- settings.hiddenSources = settings.getMangaSources(includeHidden = true).mapToSet {
- it.name
+ launchAtomicJob(Dispatchers.Default) {
+ settings.hiddenSources = settings.getMangaSources(includeHidden = true).mapToSet {
+ it.name
+ }
+ buildList()
}
- buildList()
}
fun expandOrCollapse(headerId: String?) {
- if (headerId in expandedGroups) {
- expandedGroups.remove(headerId)
- } else {
- expandedGroups.add(headerId)
+ launchAtomicJob {
+ if (headerId in expandedGroups) {
+ expandedGroups.remove(headerId)
+ } else {
+ expandedGroups.add(headerId)
+ }
+ buildList()
}
- buildList()
}
fun performSearch(query: String?) {
- searchQuery = query?.trim()
- buildList()
+ launchAtomicJob {
+ searchQuery = query?.trim()
+ buildList()
+ }
}
- private fun buildList() {
+ fun onTipClosed(item: SourceConfigItem.Tip) {
+ launchAtomicJob(Dispatchers.Default) {
+ settings.closeTip(item.key)
+ buildList()
+ }
+ }
+
+ private suspend fun buildList() = runInterruptible(Dispatchers.Default) {
val sources = settings.getMangaSources(includeHidden = true)
val hiddenSources = settings.hiddenSources
val query = searchQuery
if (!query.isNullOrEmpty()) {
- items.value = sources.mapNotNull {
- if (!it.title.contains(query, ignoreCase = true)) {
- return@mapNotNull null
- }
- SourceConfigItem.SourceItem(
- source = it,
- summary = it.getLocaleTitle(),
- isEnabled = it.name !in hiddenSources,
- isDraggable = false,
- )
- }.ifEmpty {
- listOf(SourceConfigItem.EmptySearchResult)
- }
- return
+ items.postValue(
+ sources.mapNotNull {
+ if (!it.title.contains(query, ignoreCase = true)) {
+ return@mapNotNull null
+ }
+ SourceConfigItem.SourceItem(
+ source = it,
+ summary = it.getLocaleTitle(),
+ isEnabled = it.name !in hiddenSources,
+ isDraggable = false,
+ )
+ }.ifEmpty {
+ listOf(SourceConfigItem.EmptySearchResult)
+ },
+ )
+ return@runInterruptible
}
val map = sources.groupByTo(TreeMap(LocaleKeyComparator())) {
if (it.name !in hiddenSources) {
@@ -110,10 +151,13 @@ class SourcesSettingsViewModel @Inject constructor(
it.locale
}
}
- val result = ArrayList(sources.size + map.size + 1)
+ val result = ArrayList(sources.size + map.size + 2)
val enabledSources = map.remove(KEY_ENABLED)
if (!enabledSources.isNullOrEmpty()) {
result += SourceConfigItem.Header(R.string.enabled_sources)
+ if (settings.isTipEnabled(TIP_REORDER)) {
+ result += SourceConfigItem.Tip(TIP_REORDER, R.drawable.ic_tap_reorder, R.string.sources_reorder_tip)
+ }
enabledSources.mapTo(result) {
SourceConfigItem.SourceItem(
source = it,
@@ -145,7 +189,7 @@ class SourcesSettingsViewModel @Inject constructor(
}
}
}
- items.value = result
+ items.postValue(result)
}
private fun getLocaleTitle(localeKey: String?): String? {
@@ -153,6 +197,15 @@ class SourcesSettingsViewModel @Inject constructor(
return locale.getDisplayLanguage(locale).toTitleCase(locale)
}
+ private inline fun launchAtomicJob(
+ context: CoroutineContext = EmptyCoroutineContext,
+ crossinline block: suspend CoroutineScope.() -> Unit
+ ) = launchJob(context) {
+ mutex.withLock {
+ block()
+ }
+ }
+
private class LocaleKeyComparator : Comparator {
private val deviceLocales = LocaleListCompat.getAdjustedDefault()
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt
index d580684be..2c6be9c77 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt
@@ -13,7 +13,7 @@ class SourceConfigAdapter(
SourceConfigDiffCallback(),
sourceConfigHeaderDelegate(),
sourceConfigGroupDelegate(listener),
- sourceConfigItemDelegate(listener, coil, lifecycleOwner),
- sourceConfigDraggableItemDelegate(listener),
+ sourceConfigItemDelegate2(listener, coil, lifecycleOwner),
sourceConfigEmptySearchDelegate(),
-)
\ No newline at end of file
+ sourceConfigTipDelegate(listener),
+)
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt
index cd0471af2..1f3aa5253 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt
@@ -1,21 +1,27 @@
package org.koitharu.kotatsu.settings.sources.adapter
-import android.annotation.SuppressLint
-import android.view.MotionEvent
import android.view.View
-import android.widget.CompoundButton
+import androidx.core.view.isGone
+import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.list.OnTipCloseListener
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.databinding.ItemExpandableBinding
import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding
import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding
-import org.koitharu.kotatsu.databinding.ItemSourceConfigDraggableBinding
+import org.koitharu.kotatsu.databinding.ItemSourceConfigCheckableBinding
+import org.koitharu.kotatsu.databinding.ItemTipBinding
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
-import org.koitharu.kotatsu.utils.ext.*
+import org.koitharu.kotatsu.utils.ext.crossfade
+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.source
+import org.koitharu.kotatsu.utils.ext.textAndVisible
import org.koitharu.kotatsu.utils.image.FaviconFallbackDrawable
fun sourceConfigHeaderDelegate() =
@@ -44,13 +50,12 @@ fun sourceConfigGroupDelegate(
}
}
-fun sourceConfigItemDelegate(
+fun sourceConfigItemCheckableDelegate(
listener: SourceConfigListener,
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
-) = adapterDelegateViewBinding(
- { layoutInflater, parent -> ItemSourceConfigBinding.inflate(layoutInflater, parent, false) },
- on = { item, _, _ -> item is SourceConfigItem.SourceItem && !item.isDraggable },
+) = adapterDelegateViewBinding(
+ { layoutInflater, parent -> ItemSourceConfigCheckableBinding.inflate(layoutInflater, parent, false) },
) {
binding.switchToggle.setOnCheckedChangeListener { _, isChecked ->
@@ -62,12 +67,12 @@ fun sourceConfigItemDelegate(
binding.switchToggle.isChecked = item.isEnabled
binding.textViewDescription.textAndVisible = item.summary
val fallbackIcon = FaviconFallbackDrawable(context, item.source.name)
- binding.imageViewIcon.newImageRequest(item.source.faviconUri(), item.source)?.run {
+ binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
crossfade(context)
error(fallbackIcon)
placeholder(fallbackIcon)
fallback(fallbackIcon)
- lifecycle(lifecycleOwner)
+ source(item.source)
enqueueWith(coil)
}
}
@@ -77,42 +82,60 @@ fun sourceConfigItemDelegate(
}
}
-@SuppressLint("ClickableViewAccessibility")
-fun sourceConfigDraggableItemDelegate(
+fun sourceConfigItemDelegate2(
listener: SourceConfigListener,
-) = adapterDelegateViewBinding(
- { layoutInflater, parent -> ItemSourceConfigDraggableBinding.inflate(layoutInflater, parent, false) },
- on = { item, _, _ -> item is SourceConfigItem.SourceItem && item.isDraggable },
+ coil: ImageLoader,
+ lifecycleOwner: LifecycleOwner,
+) = adapterDelegateViewBinding(
+ { layoutInflater, parent -> ItemSourceConfigBinding.inflate(layoutInflater, parent, false) },
) {
- val eventListener = object :
- View.OnClickListener,
- View.OnTouchListener,
- CompoundButton.OnCheckedChangeListener {
- override fun onClick(v: View?) = listener.onItemSettingsClick(item)
-
- override fun onTouch(v: View?, event: MotionEvent): Boolean {
- return if (event.actionMasked == MotionEvent.ACTION_DOWN) {
- listener.onDragHandleTouch(this@adapterDelegateViewBinding)
- true
- } else {
- false
- }
+ val eventListener = View.OnClickListener { v ->
+ when (v.id) {
+ R.id.imageView_add -> listener.onItemEnabledChanged(item, true)
+ R.id.imageView_remove -> listener.onItemEnabledChanged(item, false)
+ R.id.imageView_config -> listener.onItemSettingsClick(item)
}
+ }
+ binding.imageViewRemove.setOnClickListener(eventListener)
+ binding.imageViewAdd.setOnClickListener(eventListener)
+ binding.imageViewConfig.setOnClickListener(eventListener)
- override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
- listener.onItemEnabledChanged(item, isChecked)
+ bind {
+ binding.textViewTitle.text = item.source.title
+ binding.imageViewAdd.isGone = item.isEnabled
+ binding.imageViewRemove.isVisible = item.isEnabled
+ binding.imageViewConfig.isVisible = item.isEnabled
+ binding.textViewDescription.textAndVisible = item.summary
+ val fallbackIcon = FaviconFallbackDrawable(context, item.source.name)
+ binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
+ crossfade(context)
+ error(fallbackIcon)
+ placeholder(fallbackIcon)
+ fallback(fallbackIcon)
+ source(item.source)
+ enqueueWith(coil)
}
}
- binding.imageViewConfig.setOnClickListener(eventListener)
- binding.switchToggle.setOnCheckedChangeListener(eventListener)
- binding.imageViewHandle.setOnTouchListener(eventListener)
+ onViewRecycled {
+ binding.imageViewIcon.disposeImageRequest()
+ }
+}
+
+fun sourceConfigTipDelegate(
+ listener: OnTipCloseListener
+) = adapterDelegateViewBinding(
+ { layoutInflater, parent -> ItemTipBinding.inflate(layoutInflater, parent, false) },
+) {
+
+ binding.buttonClose.setOnClickListener {
+ listener.onCloseTip(item)
+ }
bind {
- binding.textViewTitle.text = item.source.title
- binding.textViewDescription.text = item.summary ?: getString(R.string.various_languages)
- binding.switchToggle.isChecked = item.isEnabled
+ binding.imageViewIcon.setImageResource(item.iconResId)
+ binding.textView.setText(item.textResId)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigDiffCallback.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigDiffCallback.kt
index 8bab50c2a..9f57bca90 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigDiffCallback.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigDiffCallback.kt
@@ -2,7 +2,10 @@ package org.koitharu.kotatsu.settings.sources.adapter
import androidx.recyclerview.widget.DiffUtil
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
-import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem.*
+import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem.EmptySearchResult
+import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem.Header
+import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem.LocaleGroup
+import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem.SourceItem
class SourceConfigDiffCallback : DiffUtil.ItemCallback() {
@@ -12,15 +15,23 @@ class SourceConfigDiffCallback : DiffUtil.ItemCallback() {
oldItem is LocaleGroup && newItem is LocaleGroup -> {
oldItem.localeId == newItem.localeId
}
+
oldItem is SourceItem && newItem is SourceItem -> {
oldItem.source == newItem.source
}
+
oldItem is Header && newItem is Header -> {
oldItem.titleResId == newItem.titleResId
}
+
oldItem == EmptySearchResult && newItem == EmptySearchResult -> {
true
}
+
+ oldItem is SourceConfigItem.Tip && newItem is SourceConfigItem.Tip -> {
+ oldItem.key == newItem.key
+ }
+
else -> false
}
}
@@ -30,4 +41,4 @@ class SourceConfigDiffCallback : DiffUtil.ItemCallback() {
}
override fun getChangePayload(oldItem: SourceConfigItem, newItem: SourceConfigItem) = Unit
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt
index 8bc03a213..d8f0be9fa 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt
@@ -1,15 +1,13 @@
package org.koitharu.kotatsu.settings.sources.adapter
-import androidx.recyclerview.widget.RecyclerView
+import org.koitharu.kotatsu.base.ui.list.OnTipCloseListener
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
-interface SourceConfigListener {
+interface SourceConfigListener : OnTipCloseListener {
fun onItemSettingsClick(item: SourceConfigItem.SourceItem)
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean)
- fun onDragHandleTouch(holder: RecyclerView.ViewHolder)
-
fun onHeaderClick(header: SourceConfigItem.LocaleGroup)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt
index 8862948d5..8c424eccd 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt
@@ -18,7 +18,6 @@ import org.koitharu.kotatsu.browser.BrowserCallback
import org.koitharu.kotatsu.browser.BrowserClient
import org.koitharu.kotatsu.browser.ProgressChromeClient
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
-import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
@@ -26,6 +25,7 @@ import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.TaggedActivityResult
+import org.koitharu.kotatsu.utils.ext.getSerializableExtraCompat
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -42,7 +42,7 @@ class SourceAuthActivity : BaseActivity(), BrowserCallba
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityBrowserBinding.inflate(layoutInflater))
- val source = intent?.getSerializableExtra(EXTRA_SOURCE) as? MangaSource
+ val source = intent?.getSerializableExtraCompat(EXTRA_SOURCE) as? MangaSource
if (source == null) {
finishAfterTransition()
return
@@ -63,8 +63,7 @@ class SourceAuthActivity : BaseActivity(), BrowserCallba
}
with(binding.webView.settings) {
javaScriptEnabled = true
- userAgentString = repository.headers?.get(CommonHeaders.USER_AGENT)
- ?: CommonHeadersInterceptor.userAgentFallback
+ userAgentString = CommonHeadersInterceptor.userAgentChrome
}
binding.webView.webViewClient = BrowserClient(this)
binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar)
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt
index 77f695002..f178bffd9 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt
@@ -1,6 +1,6 @@
package org.koitharu.kotatsu.settings.sources.model
-import android.net.Uri
+import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -77,5 +77,32 @@ sealed interface SourceConfigItem {
}
}
+ class Tip(
+ val key: String,
+ @DrawableRes val iconResId: Int,
+ @StringRes val textResId: Int,
+ ) : SourceConfigItem {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Tip
+
+ if (key != other.key) return false
+ if (iconResId != other.iconResId) return false
+ if (textResId != other.textResId) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = key.hashCode()
+ result = 31 * result + iconResId
+ result = 31 * result + textResId
+ return result
+ }
+ }
+
object EmptySearchResult : SourceConfigItem
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/tools/views/MemoryUsageView.kt b/app/src/main/java/org/koitharu/kotatsu/settings/tools/views/MemoryUsageView.kt
index b144a8131..a6eb2bc56 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/tools/views/MemoryUsageView.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/tools/views/MemoryUsageView.kt
@@ -40,7 +40,7 @@ class MemoryUsageView @JvmOverloads constructor(
val otherSegment = SegmentedBarView.Segment(usage?.otherCache?.percent ?: 0f, segmentColor(3))
with(binding) {
- bar.segments = listOf(storageSegment, pagesSegment, otherSegment).filter { it.percent > 0f }
+ bar.animateSegments(listOf(storageSegment, pagesSegment, otherSegment).filter { it.percent > 0f })
labelStorage.text = formatLabel(usage?.savedManga, R.string.saved_manga)
labelPagesCache.text = formatLabel(usage?.pagesCache, R.string.pages_cache)
labelOtherCache.text = formatLabel(usage?.otherCache, R.string.other_cache)
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt b/app/src/main/java/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt
index 42d7b70c9..2102b3788 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt
@@ -31,8 +31,7 @@ class AboutLinksPreference @JvmOverloads constructor(
binding.btn4pda,
binding.btnDiscord,
binding.btnGithub,
- binding.btnReddit,
- binding.btnTwitter,
+ binding.btnTelegram,
).forEach { button ->
TooltipCompat.setTooltipText(button, button.contentDescription)
button.setOnClickListener(this)
@@ -43,8 +42,7 @@ class AboutLinksPreference @JvmOverloads constructor(
val urlResId = when (v.id) {
R.id.btn_4pda -> R.string.url_forpda
R.id.btn_discord -> R.string.url_discord
- R.id.btn_twitter -> R.string.url_twitter
- R.id.btn_reddit -> R.string.url_reddit
+ R.id.btn_telegram -> R.string.url_telegram
R.id.btn_github -> R.string.url_github
else -> return
}
@@ -59,9 +57,9 @@ class AboutLinksPreference @JvmOverloads constructor(
Intent.createChooser(intent, title)
} else {
intent
- }
+ },
)
} catch (_: ActivityNotFoundException) {
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/domain/ShelfRepository.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/domain/ShelfRepository.kt
index 7ecfa7dbf..5da6628e2 100644
--- a/app/src/main/java/org/koitharu/kotatsu/shelf/domain/ShelfRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/shelf/domain/ShelfRepository.kt
@@ -24,7 +24,9 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject
+import javax.inject.Singleton
+@Singleton
class ShelfRepository @Inject constructor(
private val localMangaRepository: LocalMangaRepository,
private val historyRepository: HistoryRepository,
diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfFragment.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfFragment.kt
index 19dd12912..a275957ac 100644
--- a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfFragment.kt
@@ -12,14 +12,12 @@ import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
-import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
-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.list.SectionedSelectionController
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
-import org.koitharu.kotatsu.base.ui.util.ReversibleAction
+import org.koitharu.kotatsu.base.ui.util.ReversibleActionObserver
+import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.FragmentShelfBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
@@ -27,7 +25,6 @@ import org.koitharu.kotatsu.favourites.ui.FavouritesActivity
import org.koitharu.kotatsu.history.ui.HistoryActivity
import org.koitharu.kotatsu.list.ui.ItemSizeResolver
import org.koitharu.kotatsu.list.ui.model.ListModel
-import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.search.ui.MangaListActivity
@@ -36,7 +33,6 @@ import org.koitharu.kotatsu.shelf.ui.adapter.ShelfListEventListener
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity
import org.koitharu.kotatsu.utils.ext.addMenuProvider
-import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import javax.inject.Inject
@AndroidEntryPoint
@@ -82,8 +78,8 @@ class ShelfFragment :
addMenuProvider(ShelfMenuProvider(view.context, childFragmentManager, viewModel))
viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
- viewModel.onError.observe(viewLifecycleOwner, ::onError)
- viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
+ viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
+ viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
}
override fun onDestroyView() {
@@ -135,27 +131,6 @@ class ShelfFragment :
adapter?.items = list
}
- private fun onError(e: Throwable) {
- val snackbar = Snackbar.make(
- binding.recyclerView,
- e.getDisplayMessage(resources),
- Snackbar.LENGTH_SHORT,
- )
- snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav
- snackbar.show()
- }
-
- 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)
- if (handle != null) {
- snackbar.setAction(R.string.undo) { handle.reverseAsync() }
- }
- snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav
- snackbar.show()
- }
-
companion object {
fun newInstance() = ShelfFragment()
diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt
index ef806d69c..b8d203f85 100644
--- a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt
@@ -236,7 +236,7 @@ class ShelfViewModel @Inject constructor(
return
}
destination += ShelfSectionModel.Local(
- items = local.toUi(ListMode.GRID, this),
+ items = local.toUi(ListMode.GRID, this, null),
showAllButtonText = R.string.show_all,
)
}
@@ -251,7 +251,7 @@ class ShelfViewModel @Inject constructor(
for ((category, list) in favourites) {
if (list.isNotEmpty()) {
destination += ShelfSectionModel.Favourites(
- items = list.toUi(ListMode.GRID, this),
+ items = list.toUi(ListMode.GRID, this, null),
category = category,
showAllButtonText = R.string.show_all,
)
diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/adapter/ShelfAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/adapter/ShelfAdapter.kt
index 1a19dbb17..0c3347cf1 100644
--- a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/adapter/ShelfAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/adapter/ShelfAdapter.kt
@@ -42,7 +42,7 @@ class ShelfAdapter(
.addDelegate(loadingStateAD())
.addDelegate(loadingFooterAD())
.addDelegate(emptyHintAD(listener))
- .addDelegate(emptyStateListAD(coil, listener))
+ .addDelegate(emptyStateListAD(coil, lifecycleOwner, listener))
.addDelegate(errorStateListAD(listener))
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsActivity.kt
index ef28b06a5..c4dce1804 100644
--- a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsActivity.kt
@@ -14,6 +14,7 @@ import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityShelfSettingsBinding
+import com.google.android.material.R as materialR
@AndroidEntryPoint
class ShelfSettingsActivity :
@@ -28,7 +29,7 @@ class ShelfSettingsActivity :
setContentView(ActivityShelfSettingsBinding.inflate(layoutInflater))
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
- setHomeAsUpIndicator(com.google.android.material.R.drawable.abc_ic_clear_material)
+ setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
binding.buttonDone.setOnClickListener(this)
val settingsAdapter = ShelfSettingsAdapter(this)
diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsAdapterDelegates.kt
index 973391190..3c6228cdf 100644
--- a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsAdapterDelegates.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsAdapterDelegates.kt
@@ -43,6 +43,7 @@ fun shelfSectionAD(
bind {
binding.textViewTitle.setText(item.section.titleResId)
binding.switchToggle.isChecked = item.isChecked
+ binding.switchToggle.jumpDrawablesToCurrentState()
}
}
@@ -63,6 +64,7 @@ fun shelfCategoryAD(
bind {
binding.root.text = item.title
binding.root.isChecked = item.isChecked
+ binding.root.jumpDrawablesToCurrentState()
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt
index 2eca00df8..1741936f2 100644
--- a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
@@ -22,6 +23,7 @@ import javax.inject.Inject
class SuggestionsViewModel @Inject constructor(
repository: SuggestionRepository,
settings: AppSettings,
+ private val tagHighlighter: MangaTagHighlighter,
) : MangaListViewModel(settings) {
override val content = combine(
@@ -38,7 +40,7 @@ class SuggestionsViewModel @Inject constructor(
),
)
- else -> list.toUi(mode)
+ else -> list.toUi(mode, tagHighlighter)
}
}.onStart {
loadingCounter.increment()
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt
index c1038f83e..e07abd0ab 100644
--- a/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt
@@ -23,9 +23,11 @@ import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import java.util.Date
import javax.inject.Inject
+import javax.inject.Singleton
private const val NO_ID = 0L
+@Singleton
class TrackingRepository @Inject constructor(
private val db: MangaDatabase,
) {
@@ -112,6 +114,7 @@ class TrackingRepository @Inject constructor(
val track = getOrCreateTrack(updates.manga.id).mergeWith(updates)
db.tracksDao.upsert(track)
if (updates.isValid && updates.newChapters.isNotEmpty()) {
+ updatePercent(updates)
val logEntity = TrackLogEntity(
mangaId = updates.manga.id,
chapters = updates.newChapters.joinToString("\n") { x -> x.name },
@@ -175,6 +178,21 @@ class TrackingRepository @Inject constructor(
)
}
+ private suspend fun updatePercent(updates: MangaUpdates) {
+ val history = db.historyDao.find(updates.manga.id) ?: return
+ val chapters = updates.manga.chapters
+ if (chapters.isNullOrEmpty()) {
+ return
+ }
+ val chapterIndex = chapters.indexOfFirst { it.id == history.chapterId }
+ if (chapterIndex < 0) {
+ return
+ }
+ val position = (chapters.size - updates.newChapters.size) * history.percent
+ val newPercent = position / chapters.size.toFloat()
+ db.historyDao.update(history.copy(percent = newPercent))
+ }
+
private fun TrackEntity.mergeWith(updates: MangaUpdates): TrackEntity {
val chapters = updates.manga.chapters.orEmpty()
return TrackEntity(
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt
index 95b51d35a..0ca7b391e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt
@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration
+import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.databinding.FragmentFeedBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
@@ -25,7 +26,6 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.tracker.ui.feed.adapter.FeedAdapter
import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.ext.addMenuProvider
-import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getThemeColor
import javax.inject.Inject
@@ -75,7 +75,7 @@ class FeedFragment :
)
viewModel.content.observe(viewLifecycleOwner, this::onListChanged)
- viewModel.onError.observe(viewLifecycleOwner, this::onError)
+ viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onFeedCleared.observe(viewLifecycleOwner) {
onFeedCleared()
}
@@ -118,16 +118,6 @@ class FeedFragment :
snackbar.show()
}
- private fun onError(e: Throwable) {
- val snackbar = Snackbar.make(
- binding.recyclerView,
- e.getDisplayMessage(resources),
- Snackbar.LENGTH_SHORT,
- )
- snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav
- snackbar.show()
- }
-
private fun onIsTrackerRunningChanged(isRunning: Boolean) {
binding.swipeRefreshLayout.isRefreshing = isRunning
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt
index 3737e3242..b35158c6e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt
@@ -29,7 +29,7 @@ class FeedAdapter(
.addDelegate(ITEM_TYPE_LOADING_STATE, loadingStateAD())
.addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(listener))
.addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(listener))
- .addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD(coil, listener))
+ .addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listener))
.addDelegate(ITEM_TYPE_DATE_HEADER, relatedDateItemAD())
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedItemAD.kt
index 901e4573a..ce123e0b2 100644
--- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedItemAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedItemAD.kt
@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.isBold
import org.koitharu.kotatsu.utils.ext.newImageRequest
+import org.koitharu.kotatsu.utils.ext.source
fun feedItemAD(
coil: ImageLoader,
@@ -28,12 +29,12 @@ fun feedItemAD(
bind {
binding.textViewTitle.isBold = item.isNew
binding.textViewSummary.isBold = item.isNew
- binding.imageViewCover.newImageRequest(item.imageUrl, item.manga.source)?.run {
+ binding.imageViewCover.newImageRequest(lifecycleOwner, item.imageUrl)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
- lifecycle(lifecycleOwner)
+ source(item.manga.source)
enqueueWith(coil)
}
binding.textViewTitle.text = item.title
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt
index 6a649cd38..1ebe7b8cd 100644
--- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.history.domain.HistoryRepository
@@ -30,6 +31,7 @@ class UpdatesViewModel @Inject constructor(
private val repository: TrackingRepository,
private val settings: AppSettings,
private val historyRepository: HistoryRepository,
+ private val tagHighlighter: MangaTagHighlighter,
) : MangaListViewModel(settings) {
override val content = combine(
@@ -69,7 +71,7 @@ class UpdatesViewModel @Inject constructor(
val percent = if (showPercent) historyRepository.getProgress(manga.id) else PROGRESS_NONE
when (mode) {
ListMode.LIST -> manga.toListModel(counter, percent)
- ListMode.DETAILED_LIST -> manga.toListDetailedModel(counter, percent)
+ ListMode.DETAILED_LIST -> manga.toListDetailedModel(counter, percent, tagHighlighter)
ListMode.GRID -> manga.toGridModel(counter, percent)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt
index 7aaca6808..1b670e2b6 100644
--- a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt
@@ -195,9 +195,7 @@ class TrackWorker @AssistedInject constructor(
builder.setDefaults(defaults)
}
}
- withContext(Dispatchers.Main) {
- notificationManager.notify(TAG, id, builder.build())
- }
+ notificationManager.notify(TAG, id, builder.build())
}
override suspend fun getForegroundInfo(): ForegroundInfo {
@@ -214,13 +212,17 @@ class TrackWorker @AssistedInject constructor(
channel.enableLights(false)
notificationManager.createNotificationChannel(channel)
}
-
- val notification = NotificationCompat.Builder(applicationContext, WORKER_CHANNEL_ID).setContentTitle(title)
- .setPriority(NotificationCompat.PRIORITY_MIN).setDefaults(0)
- .setColor(ContextCompat.getColor(applicationContext, R.color.blue_primary_dark)).setSilent(true)
- .setProgress(0, 0, true).setSmallIcon(android.R.drawable.stat_notify_sync)
- .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED).setOngoing(true).build()
-
+ val notification = NotificationCompat.Builder(applicationContext, WORKER_CHANNEL_ID)
+ .setContentTitle(title)
+ .setPriority(NotificationCompat.PRIORITY_MIN)
+ .setDefaults(0)
+ .setColor(ContextCompat.getColor(applicationContext, R.color.blue_primary_dark))
+ .setSilent(true)
+ .setProgress(0, 0, true)
+ .setSmallIcon(android.R.drawable.stat_notify_sync)
+ .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
+ .setOngoing(true)
+ .build()
return ForegroundInfo(WORKER_NOTIFICATION_ID, notification)
}
@@ -243,16 +245,21 @@ class TrackWorker @AssistedInject constructor(
fun setup(context: Context) {
val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
- val request =
- PeriodicWorkRequestBuilder(4, TimeUnit.HOURS).setConstraints(constraints).addTag(TAG)
- .setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES).build()
+ val request = PeriodicWorkRequestBuilder(4, TimeUnit.HOURS)
+ .setConstraints(constraints)
+ .addTag(TAG)
+ .setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES)
+ .build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request)
}
fun startNow(context: Context) {
val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
- val request = OneTimeWorkRequestBuilder().setConstraints(constraints).addTag(TAG_ONESHOT)
- .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST).build()
+ val request = OneTimeWorkRequestBuilder()
+ .setConstraints(constraints)
+ .addTag(TAG_ONESHOT)
+ .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
+ .build()
WorkManager.getInstance(context).enqueue(request)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/RetainedLifecycleCoroutineScope.kt b/app/src/main/java/org/koitharu/kotatsu/utils/RetainedLifecycleCoroutineScope.kt
new file mode 100644
index 000000000..66a232922
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/RetainedLifecycleCoroutineScope.kt
@@ -0,0 +1,23 @@
+package org.koitharu.kotatsu.utils
+
+import dagger.hilt.android.lifecycle.RetainedLifecycle
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlin.coroutines.CoroutineContext
+
+class RetainedLifecycleCoroutineScope(
+ private val lifecycle: RetainedLifecycle,
+) : CoroutineScope, RetainedLifecycle.OnClearedListener {
+
+ override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate
+
+ init {
+ lifecycle.addOnClearedListener(this)
+ }
+
+ override fun onCleared() {
+ coroutineContext.cancel()
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt
index c1923c182..8535fbed3 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt
@@ -85,7 +85,11 @@ class ShareHelper(private val context: Context) {
val intentBuilder = ShareCompat.IntentBuilder(context)
.setType(TYPE_TEXT)
for (logger in loggers) {
- val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", logger.file)
+ val logFile = logger.file
+ if (!logFile.exists()) {
+ continue
+ }
+ val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", logFile)
intentBuilder.addStream(uri)
}
intentBuilder.setChooserTitle(R.string.share_logs)
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt
index 251604b12..654297d84 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt
@@ -1,9 +1,11 @@
package org.koitharu.kotatsu.utils.ext
+import android.app.Activity
import android.app.ActivityManager
import android.app.ActivityOptions
import android.content.Context
import android.content.Context.ACTIVITY_SERVICE
+import android.content.ContextWrapper
import android.content.OperationApplicationException
import android.content.SharedPreferences
import android.content.SyncResult
@@ -133,6 +135,9 @@ fun Window.setNavigationBarTransparentCompat(context: Context, elevation: Float,
val Context.animatorDurationScale: Float
get() = Settings.Global.getFloat(this.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f)
+val Context.isAnimationsEnabled: Boolean
+ get() = animatorDurationScale > 0f
+
fun ViewPropertyAnimator.applySystemAnimatorScale(context: Context): ViewPropertyAnimator = apply {
this.duration = (this.duration * context.animatorDurationScale).toLong()
}
@@ -172,3 +177,9 @@ fun Resources.getLocalesConfig(): LocaleListCompat {
}
return LocaleListCompat.forLanguageTags(tagsList.complete())
}
+
+fun Context.findActivity(): Activity? = when (this) {
+ is Activity -> this
+ is ContextWrapper -> baseContext.findActivity()
+ else -> null
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/Bundle.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/Bundle.kt
index 27ed10082..fb7a0bb02 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/Bundle.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/Bundle.kt
@@ -7,6 +7,7 @@ import android.os.Build
import android.os.Bundle
import android.os.Parcel
import android.os.Parcelable
+import androidx.lifecycle.SavedStateHandle
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags
import java.io.Serializable
@@ -20,6 +21,10 @@ inline fun Intent.getParcelableExtraCompat(key: String)
return getParcelableExtra(key) as T?
}
+inline fun Intent.getSerializableExtraCompat(key: String): T? {
+ return getSerializableExtra(key) as T?
+}
+
inline fun Bundle.getSerializableCompat(key: String): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getSerializable(key, T::class.java)
@@ -47,3 +52,9 @@ inline fun Bundle.requireParcelable(key: String): T {
"Parcelable of type \"${T::class.java.name}\" not found at \"$key\""
}
}
+
+fun SavedStateHandle.require(key: String): T {
+ return checkNotNull(get(key)) {
+ "Value $key not found in SavedStateHandle or has a wrong type"
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt
index fb2109524..4a1c18d98 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.utils.ext
import android.content.Context
import android.widget.ImageView
import androidx.core.graphics.drawable.toBitmap
+import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import coil.request.ErrorResult
import coil.request.ImageRequest
@@ -15,14 +16,14 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.image.RegionBitmapDecoder
import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener
-fun ImageView.newImageRequest(url: Any?, mangaSource: MangaSource? = null): ImageRequest.Builder? {
+fun ImageView.newImageRequest(lifecycleOwner: LifecycleOwner, data: Any?): ImageRequest.Builder? {
val current = CoilUtils.result(this)
- if (current != null && current.request.data == url) {
+ if (current != null && current.request.data == data) {
return null
}
return ImageRequest.Builder(context)
- .data(url)
- .tag(mangaSource)
+ .data(data)
+ .lifecycle(lifecycleOwner)
.crossfade(context)
.target(this)
}
@@ -53,16 +54,17 @@ fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRe
return listener(ImageRequestIndicatorListener(indicator))
}
-fun ImageRequest.Builder.decodeRegion(): ImageRequest.Builder {
- return decoderFactory(RegionBitmapDecoder.Factory())
-}
+fun ImageRequest.Builder.decodeRegion(
+ scroll: Int = RegionBitmapDecoder.SCROLL_UNDEFINED,
+): ImageRequest.Builder = decoderFactory(RegionBitmapDecoder.Factory())
+ .setParameter(RegionBitmapDecoder.PARAM_SCROLL, scroll)
@Suppress("SpellCheckingInspection")
-fun ImageRequest.Builder.crossfade(context: Context?): ImageRequest.Builder {
- if (context == null) {
- crossfade(true)
- return this
- }
+fun ImageRequest.Builder.crossfade(context: Context): ImageRequest.Builder {
val duration = context.resources.getInteger(R.integer.config_defaultAnimTime) * context.animatorDurationScale
return crossfade(duration.toInt())
}
+
+fun ImageRequest.Builder.source(source: MangaSource?): ImageRequest.Builder {
+ return tag(MangaSource::class.java, source)
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt
index 10b5fdb5c..6f6513707 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt
@@ -41,3 +41,7 @@ fun Map.findKeyByValue(value: V): K? {
inline fun Collection.filterToSet(predicate: (T) -> Boolean): Set {
return filterTo(ArraySet(size), predicate)
}
+
+fun Sequence.toListSorted(comparator: Comparator): List {
+ return toMutableList().apply { sortWith(comparator) }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt
index dd4907134..3120f2f68 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt
@@ -1,8 +1,67 @@
package org.koitharu.kotatsu.utils.ext
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleCoroutineScope
+import androidx.lifecycle.LifecycleDestroyedException
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.LifecycleObserver
+import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.CancellableContinuation
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
val processLifecycleScope: LifecycleCoroutineScope
- inline get() = ProcessLifecycleOwner.get().lifecycleScope
\ No newline at end of file
+ inline get() = ProcessLifecycleOwner.get().lifecycleScope
+
+suspend fun Lifecycle.awaitStateAtLeast(state: Lifecycle.State) {
+ if (currentState.isAtLeast(state)) {
+ return
+ }
+ suspendCancellableCoroutine { cont ->
+ val observer = ContinuationLifecycleObserver(this, cont, state)
+ addObserverFromAnyThread(observer)
+ cont.invokeOnCancellation {
+ removeObserverFromAnyThread(observer)
+ }
+ }
+}
+
+private class ContinuationLifecycleObserver(
+ private val lifecycle: Lifecycle,
+ private val continuation: CancellableContinuation,
+ private val targetState: Lifecycle.State,
+) : LifecycleEventObserver {
+
+ override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
+ if (event == Lifecycle.Event.upTo(targetState)) {
+ lifecycle.removeObserver(this)
+ continuation.resume(Unit)
+ } else if (event == Lifecycle.Event.ON_DESTROY) {
+ lifecycle.removeObserver(this)
+ continuation.resumeWithException(LifecycleDestroyedException())
+ }
+ }
+}
+
+private fun Lifecycle.addObserverFromAnyThread(observer: LifecycleObserver) {
+ val dispatcher = Dispatchers.Main.immediate
+ if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
+ dispatcher.dispatch(EmptyCoroutineContext) { addObserver(observer) }
+ } else {
+ addObserver(observer)
+ }
+}
+
+private fun Lifecycle.removeObserverFromAnyThread(observer: LifecycleObserver) {
+ val dispatcher = Dispatchers.Main.immediate
+ if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
+ dispatcher.dispatch(EmptyCoroutineContext) { removeObserver(observer) }
+ } else {
+ removeObserver(observer)
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/DisplayExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/DisplayExt.kt
index 205b98968..6f917ac1e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/DisplayExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/DisplayExt.kt
@@ -1,15 +1,25 @@
package org.koitharu.kotatsu.utils.ext
-import android.content.Context
+import android.app.Activity
+import android.graphics.Rect
import android.os.Build
+import android.util.DisplayMetrics
import android.view.Display
-import android.view.WindowManager
-import androidx.core.content.getSystemService
-val Context.displayCompat: Display?
+@Suppress("DEPRECATION")
+val Activity.displayCompat: Display
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- display
+ display ?: windowManager.defaultDisplay
} else {
- @Suppress("DEPRECATION")
- getSystemService()?.defaultDisplay
+ windowManager.defaultDisplay
}
+
+fun Activity.getDisplaySize(): Rect {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ windowManager.currentWindowMetrics.bounds
+ } else {
+ val dm = DisplayMetrics()
+ displayCompat.getRealMetrics(dm)
+ Rect(0, 0, dm.widthPixels, dm.heightPixels)
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt
index c40f4e01c..f2800f68c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt
@@ -14,6 +14,7 @@ import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import java.io.File
+import java.io.FileFilter
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
@@ -46,7 +47,7 @@ fun File.getStorageName(context: Context): String = runCatching {
fun Uri.toFileOrNull() = if (scheme == "file") path?.let(::File) else null
suspend fun File.deleteAwait() = withContext(Dispatchers.IO) {
- delete()
+ delete() || deleteRecursively()
}
fun ContentResolver.resolveName(uri: Uri): String? {
@@ -77,3 +78,19 @@ private fun computeSizeInternal(file: File): Long {
return file.length()
}
}
+
+fun File.listFilesRecursive(filter: FileFilter? = null): Sequence = sequence {
+ listFilesRecursiveImpl(this@listFilesRecursive, filter)
+}
+
+private suspend fun SequenceScope.listFilesRecursiveImpl(root: File, filter: FileFilter?) {
+ val ss = root.list() ?: return
+ for (s in ss) {
+ val f = File(root, s)
+ if (f.isDirectory) {
+ listFilesRecursiveImpl(f, filter)
+ } else if (filter == null || filter.accept(f)) {
+ yield(f)
+ }
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FragmentExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FragmentExt.kt
index 5ea1fb4af..dec45bde0 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FragmentExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FragmentExt.kt
@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.utils.ext
import android.os.Bundle
+import androidx.annotation.MainThread
import androidx.core.view.MenuProvider
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
@@ -9,9 +10,8 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.lifecycle.coroutineScope
-import java.io.Serializable
-import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
inline fun