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 T.withArgs(size: Int, block: Bundle.() -> Unit): T { val b = Bundle(size) @@ -23,19 +23,6 @@ inline fun T.withArgs(size: Int, block: Bundle.() -> Unit): T { val Fragment.viewLifecycleScope inline get() = viewLifecycleOwner.lifecycle.coroutineScope -fun Fragment.serializableArgument(name: String): Lazy { - return lazy(LazyThreadSafetyMode.NONE) { - @Suppress("UNCHECKED_CAST") - requireNotNull(arguments?.getSerializableCompat(name)) { - "No argument $name passed into ${javaClass.simpleName}" - } as T - } -} - -fun Fragment.stringArgument(name: String) = lazy(LazyThreadSafetyMode.NONE) { - arguments?.getString(name) -} - fun DialogFragment.showAllowStateLoss(manager: FragmentManager, tag: String?) { if (!manager.isStateSaved) { show(manager, tag) @@ -46,13 +33,14 @@ fun Fragment.addMenuProvider(provider: MenuProvider) { requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.STARTED) } +@MainThread suspend fun Fragment.awaitViewLifecycle(): LifecycleOwner = suspendCancellableCoroutine { cont -> val liveData = viewLifecycleOwnerLiveData - val observer = object : Observer { - override fun onChanged(result: LifecycleOwner?) { - if (result != null) { + val observer = object : Observer { + override fun onChanged(value: LifecycleOwner?) { + if (value != null) { liveData.removeObserver(this) - cont.resume(result) + cont.resume(value) } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/IO.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/IO.kt index eb0e0de12..1b05eddc3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/IO.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/IO.kt @@ -13,9 +13,11 @@ import java.io.OutputStream suspend fun InputStream.copyToSuspending( out: OutputStream, - bufferSize: Int = DEFAULT_BUFFER_SIZE + bufferSize: Int = DEFAULT_BUFFER_SIZE, + progressState: MutableStateFlow? = null, ): Long = withContext(Dispatchers.IO) { val job = currentCoroutineContext()[Job] + val total = available() var bytesCopied: Long = 0 val buffer = ByteArray(bufferSize) var bytes = read(buffer) @@ -25,6 +27,9 @@ suspend fun InputStream.copyToSuspending( job?.ensureActive() bytes = read(buffer) job?.ensureActive() + if (progressState != null && total > 0) { + progressState.value = bytesCopied / total.toFloat() + } } bytesCopied } diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt index 6024452dd..310b3a91d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt @@ -2,11 +2,6 @@ package org.koitharu.kotatsu.utils.ext import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData -import androidx.lifecycle.liveData -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.StateFlow import org.koitharu.kotatsu.utils.BufferedObserver fun LiveData.requireValue(): T = checkNotNull(value) { @@ -20,21 +15,3 @@ fun LiveData.observeWithPrevious(owner: LifecycleOwner, observer: Buffere previous = it } } - -fun StateFlow.asLiveDataDistinct( - context: CoroutineContext = EmptyCoroutineContext, -): LiveData = asLiveDataDistinct(context, value) - -fun Flow.asLiveDataDistinct( - context: CoroutineContext = EmptyCoroutineContext, - defaultValue: T, -): LiveData = liveData(context) { - if (latestValue == null) { - emit(defaultValue) - } - collect { - if (it != latestValue) { - emit(it) - } - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ProgressBarExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ProgressBarExt.kt deleted file mode 100644 index db73cb967..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ProgressBarExt.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.koitharu.kotatsu.utils.ext - -import android.os.Build -import android.widget.ProgressBar -import androidx.core.view.isVisible -import androidx.core.widget.ContentLoadingProgressBar -import com.google.android.material.progressindicator.BaseProgressIndicator - -fun ProgressBar.setProgressCompat(progress: Int, animate: Boolean) = when { - this is BaseProgressIndicator<*> -> setProgressCompat(progress, animate) - Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> setProgress(progress, animate) - else -> setProgress(progress) -} - -fun ProgressBar.showCompat() = when (this) { - is BaseProgressIndicator<*> -> show() - is ContentLoadingProgressBar -> show() - else -> isVisible = true -} - -fun ProgressBar.hideCompat() = when (this) { - is BaseProgressIndicator<*> -> hide() - is ContentLoadingProgressBar -> hide() - else -> isVisible = false -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/TextViewExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/TextViewExt.kt index 2197c4348..305b1e5df 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/TextViewExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/TextViewExt.kt @@ -6,7 +6,10 @@ import android.view.View import android.widget.TextView import androidx.annotation.AttrRes import androidx.annotation.StringRes +import androidx.annotation.StyleRes +import androidx.core.content.res.use import androidx.core.view.isGone +import androidx.core.widget.TextViewCompat var TextView.textAndVisible: CharSequence? get() = text?.takeIf { visibility == View.VISIBLE } @@ -61,3 +64,9 @@ var TextView.isBold: Boolean } setTypeface(typeface, style) } + +fun TextView.setThemeTextAppearance(@AttrRes resId: Int, @StyleRes fallback: Int) { + context.obtainStyledAttributes(intArrayOf(resId)).use { + TextViewCompat.setTextAppearance(this, it.getResourceId(0, fallback)) + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ThemeExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ThemeExt.kt index 6f034b7e2..7896da2e5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ThemeExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ThemeExt.kt @@ -2,14 +2,11 @@ package org.koitharu.kotatsu.utils.ext import android.content.Context import android.graphics.Color -import android.widget.TextView import androidx.annotation.AttrRes import androidx.annotation.ColorInt import androidx.annotation.FloatRange -import androidx.annotation.StyleRes import androidx.core.content.res.use import androidx.core.graphics.ColorUtils -import androidx.core.widget.TextViewCompat fun Context.getThemeDrawable( @AttrRes resId: Int, @@ -46,9 +43,3 @@ fun Context.getThemeColorStateList( ) = obtainStyledAttributes(intArrayOf(resId)).use { it.getColorStateList(0) } - -fun TextView.setThemeTextAppearance(@AttrRes resId: Int, @StyleRes fallback: Int) { - context.obtainStyledAttributes(intArrayOf(resId)).use { - TextViewCompat.setTextAppearance(this, it.getResourceId(0, fallback)) - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt index 5d00f4a11..95d55f40d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt @@ -165,6 +165,9 @@ fun RecyclerView.invalidateNestedItemDecorations() { } } +val View.parentView: ViewGroup? + get() = parent as? ViewGroup + val View.parents: Sequence get() = sequence { var p: ViewParent? = parent diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewModel.kt index 4b73f9503..3b840ade1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewModel.kt @@ -1,39 +1,12 @@ package org.koitharu.kotatsu.utils.ext -import androidx.activity.ComponentActivity -import androidx.activity.viewModels import androidx.annotation.MainThread import androidx.fragment.app.Fragment import androidx.fragment.app.createViewModelLazy -import androidx.fragment.app.viewModels -import androidx.lifecycle.AbstractSavedStateViewModelFactory -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.CreationExtras -@MainThread -inline fun ComponentActivity.assistedViewModels( - noinline viewModelProducer: (SavedStateHandle) -> VM, -): Lazy = viewModels { - object : AbstractSavedStateViewModelFactory(this, intent.extras) { - override fun create(key: String, modelClass: Class, handle: SavedStateHandle): T { - return requireNotNull(modelClass.cast(viewModelProducer(handle))) - } - } -} - -@MainThread -inline fun Fragment.assistedViewModels( - noinline viewModelProducer: (SavedStateHandle) -> VM, -): Lazy = viewModels { - object : AbstractSavedStateViewModelFactory(this, arguments) { - override fun create(key: String, modelClass: Class, handle: SavedStateHandle): T { - return requireNotNull(modelClass.cast(viewModelProducer(handle))) - } - } -} - @MainThread inline fun Fragment.parentFragmentViewModels( noinline extrasProducer: (() -> CreationExtras)? = null, diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/image/FaviconFallbackDrawable.kt b/app/src/main/java/org/koitharu/kotatsu/utils/image/FaviconFallbackDrawable.kt index a03ce5e80..f6fdaa7df 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/image/FaviconFallbackDrawable.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/image/FaviconFallbackDrawable.kt @@ -36,7 +36,7 @@ class FaviconFallbackDrawable( override fun onBoundsChange(bounds: Rect) { super.onBoundsChange(bounds) val innerWidth = bounds.width() - (paint.strokeWidth * 2f) - paint.textSize = getTextSizeForWidth(innerWidth, "100%") + paint.textSize = getTextSizeForWidth(innerWidth, letter) * 0.5f paint.getTextBounds(letter, 0, letter.length, textBounds) invalidateSelf() } @@ -64,4 +64,4 @@ class FaviconFallbackDrawable( val hue = (str.hashCode() % 360).absoluteValue.toFloat() return ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f)) } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/image/RegionBitmapDecoder.kt b/app/src/main/java/org/koitharu/kotatsu/utils/image/RegionBitmapDecoder.kt index 891ae70fd..9736f6776 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/image/RegionBitmapDecoder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/image/RegionBitmapDecoder.kt @@ -14,10 +14,10 @@ import coil.decode.ImageSource import coil.fetch.SourceResult import coil.request.Options import coil.size.* -import kotlin.math.roundToInt import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import kotlin.math.roundToInt class RegionBitmapDecoder( private val source: ImageSource, @@ -83,14 +83,22 @@ class RegionBitmapDecoder( val dstRatio = dstWidth / dstHeight.toDouble() val rect = if (srcRatio < dstRatio) { // probably manga - Rect(0, 0, srcWidth, (srcWidth / dstRatio).toInt()) + Rect(0, 0, srcWidth, (srcWidth / dstRatio).toInt().coerceAtLeast(1)) } else { - Rect(0, 0, (srcHeight / dstRatio).toInt(), srcHeight) + Rect(0, 0, (srcHeight / dstRatio).toInt().coerceAtLeast(1), srcHeight) + } + val scroll = options.parameters.value(PARAM_SCROLL) ?: SCROLL_UNDEFINED + if (scroll == SCROLL_UNDEFINED) { + rect.offsetTo( + (srcWidth - rect.width()) / 2, + (srcHeight - rect.height()) / 2, + ) + } else { + rect.offsetTo( + (srcWidth - rect.width()) / 2, + (scroll * dstRatio).toInt().coerceAtMost(srcHeight - rect.height()), + ) } - rect.offsetTo( - (srcWidth - rect.width()) / 2, - (srcHeight - rect.height()) / 2, - ) // Calculate the image's sample size. inSampleSize = DecodeUtils.calculateInSampleSize( @@ -148,21 +156,26 @@ class RegionBitmapDecoder( override fun hashCode() = javaClass.hashCode() } -} -private const val DEFAULT_MAX_PARALLELISM = 4 + companion object { -private inline fun Size.widthPx(scale: Scale, original: () -> Int): Int { - return if (isOriginal) original() else width.toPx(scale) -} + const val PARAM_SCROLL = "scroll" + const val SCROLL_UNDEFINED = -1 + private const val DEFAULT_MAX_PARALLELISM = 4 -private inline fun Size.heightPx(scale: Scale, original: () -> Int): Int { - return if (isOriginal) original() else height.toPx(scale) -} + private inline fun Size.widthPx(scale: Scale, original: () -> Int): Int { + return if (isOriginal) original() else width.toPx(scale) + } -private fun Dimension.toPx(scale: Scale) = pxOrElse { - when (scale) { - Scale.FILL -> Int.MIN_VALUE - Scale.FIT -> Int.MAX_VALUE + private inline fun Size.heightPx(scale: Scale, original: () -> Int): Int { + return if (isOriginal) original() else height.toPx(scale) + } + + private fun Dimension.toPx(scale: Scale) = pxOrElse { + when (scale) { + Scale.FILL -> Int.MIN_VALUE + Scale.FIT -> Int.MAX_VALUE + } + } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt index 5723cae17..8956c4021 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.utils.progress import android.os.Parcelable import kotlinx.parcelize.Parcelize +@Deprecated("Should be replaced with Float") @Parcelize data class Progress( val value: Int, @@ -21,4 +22,4 @@ data class Progress( get() = total <= 0 private fun part() = if (isIndeterminate) -1.0 else value / total.toDouble() -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt index a6a987cc7..b5d3f5eb0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt @@ -11,17 +11,16 @@ 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 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.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.prefs.AppWidgetConfig import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding -import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.widget.shelf.adapter.CategorySelectAdapter import org.koitharu.kotatsu.widget.shelf.model.CategoryItem +import com.google.android.material.R as materialR @AndroidEntryPoint class ShelfConfigActivity : @@ -58,7 +57,7 @@ class ShelfConfigActivity : viewModel.checkedId = config.categoryId viewModel.content.observe(this, this::onContentChanged) - viewModel.onError.observe(this, this::onError) + viewModel.onError.observe(this, SnackbarErrorObserver(binding.recyclerView, null)) } override fun onClick(v: View) { @@ -105,11 +104,6 @@ class ShelfConfigActivity : adapter.items = categories } - private fun onError(e: Throwable) { - Snackbar.make(binding.recyclerView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG) - .show() - } - private fun updateWidget() { val intent = Intent(this, ShelfWidgetProvider::class.java) intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigViewModel.kt index 9c3772efb..d8d2f2599 100644 --- a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigViewModel.kt @@ -3,14 +3,14 @@ package org.koitharu.kotatsu.widget.shelf 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.MutableStateFlow import kotlinx.coroutines.flow.combine import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.favourites.domain.FavouritesRepository -import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct +import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.widget.shelf.model.CategoryItem +import javax.inject.Inject @HiltViewModel class ShelfConfigViewModel @Inject constructor( @@ -29,7 +29,7 @@ class ShelfConfigViewModel @Inject constructor( CategoryItem(it.id, it.title, selectedId == it.id) } list - }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) + }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) var checkedId: Long by selectedCategoryId::value } diff --git a/app/src/main/res/drawable/avd_splash.xml b/app/src/main/res/drawable/avd_splash.xml new file mode 100644 index 000000000..205c9b139 --- /dev/null +++ b/app/src/main/res/drawable/avd_splash.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_backup_restore.xml b/app/src/main/res/drawable/ic_backup_restore.xml new file mode 100644 index 000000000..d2066791d --- /dev/null +++ b/app/src/main/res/drawable/ic_backup_restore.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml deleted file mode 100644 index 1048bdfff..000000000 --- a/app/src/main/res/drawable/ic_launcher_monochrome.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_reddit.xml b/app/src/main/res/drawable/ic_reddit.xml deleted file mode 100644 index 047683dbc..000000000 --- a/app/src/main/res/drawable/ic_reddit.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_tap_reorder.xml b/app/src/main/res/drawable/ic_tap_reorder.xml new file mode 100644 index 000000000..81a180b71 --- /dev/null +++ b/app/src/main/res/drawable/ic_tap_reorder.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_telegram.xml b/app/src/main/res/drawable/ic_telegram.xml new file mode 100644 index 000000000..c18d3a442 --- /dev/null +++ b/app/src/main/res/drawable/ic_telegram.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_twitter.xml b/app/src/main/res/drawable/ic_twitter.xml deleted file mode 100644 index 3e51fdce1..000000000 --- a/app/src/main/res/drawable/ic_twitter.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/layout-v23/item_loading_footer.xml b/app/src/main/res/layout-v23/item_loading_footer.xml deleted file mode 100644 index 99a165524..000000000 --- a/app/src/main/res/layout-v23/item_loading_footer.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout-v23/item_loading_state.xml b/app/src/main/res/layout-v23/item_loading_state.xml deleted file mode 100644 index 4d5d60aee..000000000 --- a/app/src/main/res/layout-v23/item_loading_state.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_category_edit.xml b/app/src/main/res/layout/activity_category_edit.xml index 11f9bbf1a..e8cc3faac 100644 --- a/app/src/main/res/layout/activity_category_edit.xml +++ b/app/src/main/res/layout/activity_category_edit.xml @@ -78,6 +78,14 @@ android:visibility="gone" tools:visibility="visible" /> + + - + app:icon="@drawable/ic_file_zip" + app:subtitle="@string/comics_archive_import_description" + app:title="@string/comics_archive" /> - + app:icon="@drawable/ic_folder_file" + app:subtitle="@string/folder_with_images_import_description" + app:title="@string/folder_with_images" /> + + + + diff --git a/app/src/main/res/layout/fragment_settings_sources.xml b/app/src/main/res/layout/fragment_settings_sources.xml index c65d18705..82802d046 100644 --- a/app/src/main/res/layout/fragment_settings_sources.xml +++ b/app/src/main/res/layout/fragment_settings_sources.xml @@ -10,4 +10,4 @@ android:orientation="vertical" android:scrollbars="vertical" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" - tools:listitem="@layout/item_source_config" /> \ No newline at end of file + tools:listitem="@layout/item_source_config_checkable" /> diff --git a/app/src/main/res/layout/item_loading_footer.xml b/app/src/main/res/layout/item_loading_footer.xml index 8f3db6dd2..f1237f529 100644 --- a/app/src/main/res/layout/item_loading_footer.xml +++ b/app/src/main/res/layout/item_loading_footer.xml @@ -1,15 +1,16 @@ - + app:indicatorSize="24dp" /> - \ No newline at end of file + diff --git a/app/src/main/res/layout/item_loading_state.xml b/app/src/main/res/layout/item_loading_state.xml index 22a462b94..ab836f167 100644 --- a/app/src/main/res/layout/item_loading_state.xml +++ b/app/src/main/res/layout/item_loading_state.xml @@ -5,10 +5,10 @@ android:layout_height="match_parent" android:paddingBottom="?actionBarSize"> - - \ No newline at end of file + diff --git a/app/src/main/res/layout/item_source_config.xml b/app/src/main/res/layout/item_source_config.xml index 6e16e217e..599ffbfcd 100644 --- a/app/src/main/res/layout/item_source_config.xml +++ b/app/src/main/res/layout/item_source_config.xml @@ -5,15 +5,17 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" + android:background="?android:windowBackground" android:gravity="center_vertical" - android:minHeight="?android:listPreferredItemHeightSmall" - android:orientation="horizontal"> + android:orientation="horizontal" + android:paddingVertical="@dimen/margin_small" + android:paddingStart="?listPreferredItemPaddingStart" + android:paddingEnd="?listPreferredItemPaddingEnd"> - + android:background="?selectableItemBackgroundBorderless" + android:contentDescription="@string/settings" + android:padding="@dimen/margin_small" + android:scaleType="center" + android:src="@drawable/ic_settings" /> + + + + diff --git a/app/src/main/res/layout/item_source_config_draggable.xml b/app/src/main/res/layout/item_source_config_checkable.xml similarity index 63% rename from app/src/main/res/layout/item_source_config_draggable.xml rename to app/src/main/res/layout/item_source_config_checkable.xml index f6ae672dd..9bb6ae9ad 100644 --- a/app/src/main/res/layout/item_source_config_draggable.xml +++ b/app/src/main/res/layout/item_source_config_checkable.xml @@ -1,26 +1,29 @@ - - + android:orientation="horizontal" + android:paddingVertical="@dimen/margin_small" + android:paddingStart="?listPreferredItemPaddingStart" + android:paddingEnd="?listPreferredItemPaddingEnd"> + + - - - \ No newline at end of file + diff --git a/app/src/main/res/layout/item_tip.xml b/app/src/main/res/layout/item_tip.xml new file mode 100644 index 000000000..0beb8d9f9 --- /dev/null +++ b/app/src/main/res/layout/item_tip.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + +