Merge branch 'devel' into feature/colorfilter

pull/216/head
Koitharu 4 years ago
commit 6a0a4023ad
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -7,16 +7,16 @@ plugins {
} }
android { android {
compileSdkVersion 32 compileSdkVersion 33
buildToolsVersion '32.0.0' buildToolsVersion '33.0.0'
namespace 'org.koitharu.kotatsu' namespace 'org.koitharu.kotatsu'
defaultConfig { defaultConfig {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 32 targetSdkVersion 33
versionCode 490 versionCode 493
versionName '4.0-a1' versionName '4.0-a4'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -29,6 +29,8 @@ android {
// define this values in your local.properties file // define this values in your local.properties file
buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', "\"${localProperty('shikimori.clientId')}\"" buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', "\"${localProperty('shikimori.clientId')}\""
buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', "\"${localProperty('shikimori.clientSecret')}\"" buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', "\"${localProperty('shikimori.clientSecret')}\""
resValue "string", "acra_login", "${localProperty('acra.login')}"
resValue "string", "acra_password", "${localProperty('acra.password')}"
} }
buildTypes { buildTypes {
debug { debug {
@ -80,7 +82,7 @@ afterEvaluate {
} }
} }
dependencies { dependencies {
implementation('com.github.KotatsuApp:kotatsu-parsers:8709c3dd0c') { implementation('com.github.KotatsuApp:kotatsu-parsers:f112a06ab6') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
@ -88,7 +90,7 @@ dependencies {
implementation 'androidx.core:core-ktx:1.8.0' implementation 'androidx.core:core-ktx:1.8.0'
implementation 'androidx.activity:activity-ktx:1.5.1' implementation 'androidx.activity:activity-ktx:1.5.1'
implementation 'androidx.fragment:fragment-ktx:1.5.1' implementation 'androidx.fragment:fragment-ktx:1.5.2'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-livedata-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-service:2.5.1'
@ -100,7 +102,7 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.7.1' implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04'
implementation 'com.google.android.material:material:1.7.0-alpha03' implementation 'com.google.android.material:material:1.7.0-beta01'
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1' kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1'
@ -115,18 +117,19 @@ dependencies {
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation "com.google.dagger:hilt-android:2.42" implementation "com.google.dagger:hilt-android:2.43.2"
kapt "com.google.dagger:hilt-compiler:2.42" kapt "com.google.dagger:hilt-compiler:2.43.2"
implementation 'androidx.hilt:hilt-work:1.0.0' implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0' kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.1.0' implementation 'io.coil-kt:coil-base:2.2.0'
implementation 'io.coil-kt:coil-svg:2.1.0' implementation 'io.coil-kt:coil-svg:2.2.0'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' // implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:2942b797a2'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'ch.acra:acra-mail:5.9.5' implementation 'ch.acra:acra-http:5.9.6'
implementation 'ch.acra:acra-dialog:5.9.5' implementation 'ch.acra:acra-dialog:5.9.6'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
@ -144,6 +147,6 @@ dependencies {
androidTestImplementation 'androidx.room:room-testing:2.4.3' androidTestImplementation 'androidx.room:room-testing:2.4.3'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.13.0' androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.13.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.42' androidTestImplementation 'com.google.dagger:hilt-android-testing:2.43.2'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.42' kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.43.2'
} }

@ -11,3 +11,6 @@
-keep public class ** extends org.koitharu.kotatsu.base.ui.BaseFragment -keep public class ** extends org.koitharu.kotatsu.base.ui.BaseFragment
-keep class org.koitharu.kotatsu.core.db.entity.* { *; } -keep class org.koitharu.kotatsu.core.db.entity.* { *; }
-dontwarn okhttp3.internal.platform.ConscryptPlatform -dontwarn okhttp3.internal.platform.ConscryptPlatform
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment

@ -17,6 +17,7 @@
<uses-permission android:name="android.permission.READ_SYNC_STATS" /> <uses-permission android:name="android.permission.READ_SYNC_STATS" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" /> <uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" /> <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:name="org.koitharu.kotatsu.KotatsuApp" android:name="org.koitharu.kotatsu.KotatsuApp"

@ -15,9 +15,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.acra.ReportField import org.acra.ReportField
import org.acra.config.dialog import org.acra.config.dialog
import org.acra.config.mailSender import org.acra.config.httpSender
import org.acra.data.StringFormat import org.acra.data.StringFormat
import org.acra.ktx.initAcra import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
@ -59,16 +60,24 @@ class KotatsuApp : Application(), Configuration.Provider {
super.attachBaseContext(base) super.attachBaseContext(base)
initAcra { initAcra {
buildConfigClass = BuildConfig::class.java buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.KEY_VALUE_LIST reportFormat = StringFormat.JSON
excludeMatchingSharedPreferencesKeys = listOf(
"sources_\\w+",
)
httpSender {
uri = getString(R.string.url_error_report)
basicAuthLogin = getString(R.string.acra_login)
basicAuthPassword = getString(R.string.acra_password)
httpMethod = HttpSender.Method.POST
}
reportContent = listOf( reportContent = listOf(
ReportField.PACKAGE_NAME, ReportField.PACKAGE_NAME,
ReportField.APP_VERSION_CODE, ReportField.APP_VERSION_CODE,
ReportField.APP_VERSION_NAME, ReportField.APP_VERSION_NAME,
ReportField.ANDROID_VERSION, ReportField.ANDROID_VERSION,
ReportField.PHONE_MODEL, ReportField.PHONE_MODEL,
ReportField.CRASH_CONFIGURATION,
ReportField.STACK_TRACE, ReportField.STACK_TRACE,
ReportField.CUSTOM_DATA, ReportField.CRASH_CONFIGURATION,
ReportField.SHARED_PREFERENCES, ReportField.SHARED_PREFERENCES,
) )
dialog { dialog {
@ -78,11 +87,6 @@ class KotatsuApp : Application(), Configuration.Provider {
resIcon = R.drawable.ic_alert_outline resIcon = R.drawable.ic_alert_outline
resTheme = android.R.style.Theme_Material_Light_Dialog_Alert resTheme = android.R.style.Theme_Material_Light_Dialog_Alert
} }
mailSender {
mailTo = getString(R.string.email_error_report)
reportAsFile = true
reportFileName = "stacktrace.txt"
}
} }
} }

@ -5,6 +5,8 @@ import android.net.Uri
import android.os.Bundle import android.os.Bundle
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.getParcelableCompat
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
class MangaIntent private constructor( class MangaIntent private constructor(
val manga: Manga?, val manga: Manga?,
@ -13,15 +15,15 @@ class MangaIntent private constructor(
) { ) {
constructor(intent: Intent?) : this( constructor(intent: Intent?) : this(
manga = intent?.getParcelableExtra<ParcelableManga>(KEY_MANGA)?.manga, manga = intent?.getParcelableExtraCompat<ParcelableManga>(KEY_MANGA)?.manga,
mangaId = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE, mangaId = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE,
uri = intent?.data uri = intent?.data,
) )
constructor(args: Bundle?) : this( constructor(args: Bundle?) : this(
manga = args?.getParcelable<ParcelableManga>(KEY_MANGA)?.manga, manga = args?.getParcelableCompat<ParcelableManga>(KEY_MANGA)?.manga,
mangaId = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE, mangaId = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE,
uri = null uri = null,
) )
companion object { companion object {

@ -21,14 +21,14 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
viewBinding = binding viewBinding = binding
return MaterialAlertDialogBuilder(requireContext(), theme) return MaterialAlertDialogBuilder(requireContext(), theme)
.setView(binding.root) .setView(binding.root)
.also(::onBuildDialog) .run(::onBuildDialog)
.create() .create()
} }
final override fun onCreateView( final override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?,
) = viewBinding?.root ) = viewBinding?.root
@CallSuper @CallSuper
@ -37,7 +37,7 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
super.onDestroyView() super.onDestroyView()
} }
open fun onBuildDialog(builder: MaterialAlertDialogBuilder) = Unit open fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder = builder
protected fun bindingOrNull(): B? = viewBinding protected fun bindingOrNull(): B? = viewBinding

@ -8,16 +8,18 @@ import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator import android.view.animation.DecelerateInterpolator
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import kotlin.math.hypot
import org.koitharu.kotatsu.utils.ext.animatorDurationScale import org.koitharu.kotatsu.utils.ext.animatorDurationScale
import org.koitharu.kotatsu.utils.ext.measureWidth import org.koitharu.kotatsu.utils.ext.measureWidth
import kotlin.math.hypot
class BubbleAnimator( class BubbleAnimator(
private val bubble: View, private val bubble: View,
) { ) {
private val animationDuration = (bubble.resources.getInteger(android.R.integer.config_shortAnimTime) * private val animationDuration = (
bubble.context.animatorDurationScale).toLong() bubble.resources.getInteger(android.R.integer.config_shortAnimTime) *
bubble.context.animatorDurationScale
).toLong()
private var animator: Animator? = null private var animator: Animator? = null
private var isHiding = false private var isHiding = false
@ -65,12 +67,12 @@ class BubbleAnimator(
private var isCancelled = false private var isCancelled = false
override fun onAnimationCancel(animation: Animator?) { override fun onAnimationCancel(animation: Animator) {
super.onAnimationCancel(animation) super.onAnimationCancel(animation)
isCancelled = true isCancelled = true
} }
override fun onAnimationEnd(animation: Animator?) { override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation) super.onAnimationEnd(animation)
if (!isCancelled && animation === this@BubbleAnimator.animator) { if (!isCancelled && animation === this@BubbleAnimator.animator) {
bubble.isInvisible = true bubble.isInvisible = true

@ -14,8 +14,10 @@ class ScrollbarAnimator(
private val scrollbarPaddingEnd: Float, private val scrollbarPaddingEnd: Float,
) { ) {
private val animationDuration = (scrollbar.resources.getInteger(R.integer.config_defaultAnimTime) * private val animationDuration = (
scrollbar.context.animatorDurationScale).toLong() scrollbar.resources.getInteger(R.integer.config_defaultAnimTime) *
scrollbar.context.animatorDurationScale
).toLong()
private var animator: ViewPropertyAnimator? = null private var animator: ViewPropertyAnimator? = null
private var isHiding = false private var isHiding = false
@ -40,26 +42,28 @@ class ScrollbarAnimator(
} }
animator?.cancel() animator?.cancel()
isHiding = true isHiding = true
animator = scrollbar animator = scrollbar.animate().apply {
.animate() translationX(scrollbarPaddingEnd)
.translationX(scrollbarPaddingEnd) alpha(0f)
.alpha(0f) duration = animationDuration
.setDuration(animationDuration) setListener(HideListener(this))
.setListener(HideListener()) }
} }
private inner class HideListener : AnimatorListenerAdapter() { private inner class HideListener(
private val viewPropertyAnimator: ViewPropertyAnimator,
) : AnimatorListenerAdapter() {
private var isCancelled = false private var isCancelled = false
override fun onAnimationCancel(animation: Animator?) { override fun onAnimationCancel(animation: Animator) {
super.onAnimationCancel(animation) super.onAnimationCancel(animation)
isCancelled = true isCancelled = true
} }
override fun onAnimationEnd(animation: Animator?) { override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation) super.onAnimationEnd(animation)
if (!isCancelled && animation === this@ScrollbarAnimator.animator) { if (!isCancelled && this@ScrollbarAnimator.animator === viewPropertyAnimator) {
scrollbar.isInvisible = true scrollbar.isInvisible = true
isHiding = false isHiding = false
this@ScrollbarAnimator.animator = null this@ScrollbarAnimator.animator = null

@ -2,15 +2,11 @@ package org.koitharu.kotatsu.base.ui.widgets
import android.animation.LayoutTransition import android.animation.LayoutTransition
import android.content.Context import android.content.Context
import android.transition.AutoTransition
import android.transition.TransitionManager
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowInsets import android.view.WindowInsets
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.DecelerateInterpolator
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
@ -30,6 +26,8 @@ import org.koitharu.kotatsu.utils.ext.getAnimationDuration
import org.koitharu.kotatsu.utils.ext.getThemeDrawable import org.koitharu.kotatsu.utils.ext.getThemeDrawable
import org.koitharu.kotatsu.utils.ext.parents import org.koitharu.kotatsu.utils.ext.parents
private const val THROTTLE_DELAY = 200L
class BottomSheetHeaderBar @JvmOverloads constructor( class BottomSheetHeaderBar @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
@ -39,11 +37,17 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
private val binding = LayoutSheetHeaderBinding.inflate(LayoutInflater.from(context), this) private val binding = LayoutSheetHeaderBinding.inflate(LayoutInflater.from(context), this)
private val closeDrawable = context.getThemeDrawable(materialR.attr.actionModeCloseDrawable) private val closeDrawable = context.getThemeDrawable(materialR.attr.actionModeCloseDrawable)
private val bottomSheetCallback = Callback() private val bottomSheetCallback = Callback()
private val adjustStateRunnable = Runnable { adjustState() }
private var bottomSheetBehavior: BottomSheetBehavior<*>? = null private var bottomSheetBehavior: BottomSheetBehavior<*>? = null
private val locationBuffer = IntArray(2) private val locationBuffer = IntArray(2)
private val expansionListeners = LinkedList<OnExpansionChangeListener>() private val expansionListeners = LinkedList<OnExpansionChangeListener>()
private var fitStatusBar = false private var fitStatusBar = false
private var transition: AutoTransition? = null private val minHandleHeight = context.resources.getDimensionPixelSize(R.dimen.bottom_sheet_handle_size_min)
private val maxHandleHeight = context.resources.getDimensionPixelSize(R.dimen.bottom_sheet_handle_size_max)
private var isLayoutSuppressedCompat = false
private var isLayoutCalledWhileSuppressed = false
private var isBsExpanded = false
private var stateAdjustedAt = 0L
@Deprecated("") @Deprecated("")
val toolbar: MaterialToolbar val toolbar: MaterialToolbar
@ -156,6 +160,14 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
binding.toolbar.setSubtitle(resId) binding.toolbar.setSubtitle(resId)
} }
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
if (isLayoutSuppressedCompat) {
isLayoutCalledWhileSuppressed = true
} else {
super.onLayout(changed, l, t, r, b)
}
}
private fun setBottomSheetBehavior(behavior: BottomSheetBehavior<*>?) { private fun setBottomSheetBehavior(behavior: BottomSheetBehavior<*>?) {
bottomSheetBehavior?.removeBottomSheetCallback(bottomSheetCallback) bottomSheetBehavior?.removeBottomSheetCallback(bottomSheetCallback)
bottomSheetBehavior = behavior bottomSheetBehavior = behavior
@ -166,15 +178,20 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
} }
private fun onBottomSheetStateChanged(newState: Int) { private fun onBottomSheetStateChanged(newState: Int) {
val isExpanded = newState == BottomSheetBehavior.STATE_EXPANDED && isOnTopOfScreen() val expanded = newState == BottomSheetBehavior.STATE_EXPANDED && isOnTopOfScreen()
if (isExpanded == binding.dragHandle.isGone) { if (isBsExpanded != expanded) {
return isBsExpanded = expanded
postAdjustState()
} }
TransitionManager.beginDelayedTransition(this, getTransition()) }
binding.toolbar.navigationIcon = (if (isExpanded) closeDrawable else null)
binding.dragHandle.isGone = isExpanded private fun suppressLayoutCompat(suppress: Boolean) {
expansionListeners.forEach { it.onExpansionStateChanged(this, isExpanded) } if (suppress == isLayoutSuppressedCompat) return
dispatchInsets(ViewCompat.getRootWindowInsets(this)) isLayoutSuppressedCompat = suppress
if (!suppress && isLayoutCalledWhileSuppressed) {
requestLayout()
}
isLayoutCalledWhileSuppressed = false
} }
private fun dispatchInsets(insets: WindowInsetsCompat?) { private fun dispatchInsets(insets: WindowInsetsCompat?) {
@ -182,11 +199,14 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
return return
} }
val isExpanded = binding.dragHandle.isGone val isExpanded = binding.dragHandle.isGone
if (isExpanded) {
val topInset = insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0 val topInset = insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0
if (isExpanded) {
updatePadding(top = topInset) updatePadding(top = topInset)
} else { } else {
updatePadding(top = 0) updatePadding(top = 0)
binding.dragHandle.updateLayoutParams {
height = topInset.coerceIn(minHandleHeight, maxHandleHeight)
}
} }
} }
@ -225,7 +245,7 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
return true return true
} }
val viewId = child.id val viewId = child.id
return viewId == R.id.dragHandle || viewId == R.id.toolbar || viewId == R.id.frame return viewId == R.id.dragHandle || viewId == R.id.toolbar
} }
private fun convertLayoutParams(params: ViewGroup.LayoutParams?): Toolbar.LayoutParams? { private fun convertLayoutParams(params: ViewGroup.LayoutParams?): Toolbar.LayoutParams? {
@ -242,13 +262,24 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
} }
} }
private fun getTransition(): AutoTransition { private fun postAdjustState() {
transition?.let { return it } removeCallbacks(adjustStateRunnable)
val t = AutoTransition() val now = System.currentTimeMillis()
t.duration = context.getAnimationDuration(android.R.integer.config_shortAnimTime) if (stateAdjustedAt + THROTTLE_DELAY < now) {
t.addTarget(binding.dragHandle) adjustState()
transition = t } else {
return t postDelayed(adjustStateRunnable, THROTTLE_DELAY)
}
}
private fun adjustState() {
suppressLayoutCompat(true)
binding.toolbar.navigationIcon = (if (isBsExpanded) closeDrawable else null)
binding.dragHandle.isGone = isBsExpanded
expansionListeners.forEach { it.onExpansionStateChanged(this, isBsExpanded) }
dispatchInsets(ViewCompat.getRootWindowInsets(this))
stateAdjustedAt = System.currentTimeMillis()
suppressLayoutCompat(false)
} }
private inner class Callback : BottomSheetBehavior.BottomSheetCallback(), View.OnClickListener { private inner class Callback : BottomSheetBehavior.BottomSheetCallback(), View.OnClickListener {

@ -100,7 +100,7 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
.applySystemAnimatorScale(context) .applySystemAnimatorScale(context)
.setListener( .setListener(
object : AnimatorListenerAdapter() { object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) { override fun onAnimationEnd(animation: Animator) {
currentAnimator = null currentAnimator = null
postInvalidate() postInvalidate()
} }

@ -42,7 +42,7 @@ class BookmarksViewModel @Inject constructor(
BookmarksGroup(manga, bookmarks) BookmarksGroup(manga, bookmarks)
} }
} }
.catch { e -> e.toErrorState(canRetry = false) } .catch { e -> emit(listOf(e.toErrorState(canRetry = false))) }
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
fun removeBookmarks(ids: Map<Manga, Set<Long>>) { fun removeBookmarks(ids: Map<Manga, Set<Long>>) {

@ -55,11 +55,12 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
override fun onDestroyView() { override fun onDestroyView() {
binding.webView.stopLoading() binding.webView.stopLoading()
binding.webView.destroy()
super.onDestroyView() super.onDestroyView()
} }
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
builder.setNegativeButton(android.R.string.cancel, null) return super.onBuildDialog(builder).setNegativeButton(android.R.string.cancel, null)
} }
override fun onResume() { override fun onResume() {
@ -83,7 +84,7 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
override fun onCheckPassed() { override fun onCheckPassed() {
pendingResult.putBoolean(EXTRA_RESULT, true) pendingResult.putBoolean(EXTRA_RESULT, true)
dismiss() dismissAllowingStateLoss()
} }
companion object { companion object {

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.db.dao package org.koitharu.kotatsu.core.db.dao
import androidx.room.* import androidx.room.*
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
@ -8,8 +9,8 @@ import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
interface TrackLogsDao { interface TrackLogsDao {
@Transaction @Transaction
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET :offset") @Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0")
suspend fun findAll(offset: Int, limit: Int): List<TrackLogWithManga> fun observeAll(limit: Int): Flow<List<TrackLogWithManga>>
@Query("DELETE FROM track_logs") @Query("DELETE FROM track_logs")
suspend fun clear() suspend fun clear()

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.db.entity package org.koitharu.kotatsu.core.db.entity
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
@ -10,7 +11,7 @@ import org.koitharu.kotatsu.utils.ext.longHashCode
fun TagEntity.toMangaTag() = MangaTag( fun TagEntity.toMangaTag() = MangaTag(
key = this.key, key = this.key,
title = this.title.toTitleCase(), title = this.title.toTitleCase(),
source = MangaSource.valueOf(this.source), source = MangaSource(this.source) ?: MangaSource.DUMMY,
) )
fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag) fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
@ -19,7 +20,7 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
id = this.id, id = this.id,
title = this.title, title = this.title,
altTitle = this.altTitle, altTitle = this.altTitle,
state = this.state?.let { MangaState.valueOf(it) }, state = this.state?.let { MangaState(it) },
rating = this.rating, rating = this.rating,
isNsfw = this.isNsfw, isNsfw = this.isNsfw,
url = this.url, url = this.url,
@ -27,8 +28,8 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
coverUrl = this.coverUrl, coverUrl = this.coverUrl,
largeCoverUrl = this.largeCoverUrl, largeCoverUrl = this.largeCoverUrl,
author = this.author, author = this.author,
source = MangaSource.valueOf(this.source), source = MangaSource(this.source) ?: MangaSource.DUMMY,
tags = tags tags = tags,
) )
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags()) fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
@ -54,14 +55,18 @@ fun MangaTag.toEntity() = TagEntity(
title = title, title = title,
key = key, key = key,
source = source.name, source = source.name,
id = "${key}_${source.name}".longHashCode() id = "${key}_${source.name}".longHashCode(),
) )
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity) fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
// Other // Other
@Suppress("FunctionName")
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching { fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
SortOrder.valueOf(name) SortOrder.valueOf(name)
}.getOrDefault(fallback) }.getOrDefault(fallback)
@Suppress("FunctionName")
fun MangaState(name: String): MangaState? = runCatching {
MangaState.valueOf(name)
}.getOrNull()

@ -11,8 +11,10 @@ import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
@ -53,11 +55,11 @@ class AppUpdateRepository @Inject constructor(
} }
} }
suspend fun fetchUpdate(): AppVersion? { suspend fun fetchUpdate(): AppVersion? = withContext(Dispatchers.Default) {
if (!isUpdateSupported()) { if (!isUpdateSupported()) {
return null return@withContext null
} }
return runCatching { runCatching {
val currentVersion = VersionId(BuildConfig.VERSION_NAME) val currentVersion = VersionId(BuildConfig.VERSION_NAME)
val available = getAvailableVersions().asArrayList() val available = getAvailableVersions().asArrayList()
available.sortBy { it.versionId } available.sortBy { it.versionId }

@ -2,10 +2,12 @@ package org.koitharu.kotatsu.core.os
import android.app.ActivityManager import android.app.ActivityManager
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.content.pm.ShortcutManager import android.content.pm.ShortcutManager
import android.media.ThumbnailUtils import android.media.ThumbnailUtils
import android.os.Build import android.os.Build
import android.util.Size import android.util.Size
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
@ -22,6 +24,7 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
@ -35,13 +38,18 @@ class ShortcutsUpdater @Inject constructor(
private val coil: ImageLoader, private val coil: ImageLoader,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val mangaRepository: MangaDataRepository, private val mangaRepository: MangaDataRepository,
) : InvalidationTracker.Observer(TABLE_HISTORY) { private val settings: AppSettings,
) : InvalidationTracker.Observer(TABLE_HISTORY), SharedPreferences.OnSharedPreferenceChangeListener {
private val iconSize by lazy { getIconSize(context) } private val iconSize by lazy { getIconSize(context) }
private var shortcutsUpdateJob: Job? = null private var shortcutsUpdateJob: Job? = null
override fun onInvalidated(tables: MutableSet<String>) { init {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) { settings.subscribe(this)
}
override fun onInvalidated(tables: Set<String>) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1 || !settings.isDynamicShortcutsEnabled) {
return return
} }
val prevJob = shortcutsUpdateJob val prevJob = shortcutsUpdateJob
@ -51,6 +59,16 @@ class ShortcutsUpdater @Inject constructor(
} }
} }
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 && key == AppSettings.KEY_SHORTCUTS) {
if (settings.isDynamicShortcutsEnabled) {
onInvalidated(emptySet())
} else {
clearShortcuts()
}
}
}
suspend fun requestPinShortcut(manga: Manga): Boolean { suspend fun requestPinShortcut(manga: Manga): Boolean {
return ShortcutManagerCompat.requestPinShortcut( return ShortcutManagerCompat.requestPinShortcut(
context, context,
@ -64,6 +82,15 @@ class ShortcutsUpdater @Inject constructor(
return shortcutsUpdateJob?.join() != null return shortcutsUpdateJob?.join() != null
} }
fun isDynamicShortcutsAvailable(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
return false
}
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
return manager.maxShortcutCountPerActivity > 0
}
@RequiresApi(Build.VERSION_CODES.N_MR1)
private suspend fun updateShortcutsImpl() = runCatching { private suspend fun updateShortcutsImpl() = runCatching {
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity) val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity)
@ -74,6 +101,15 @@ class ShortcutsUpdater @Inject constructor(
it.printStackTraceDebug() it.printStackTraceDebug()
} }
@RequiresApi(Build.VERSION_CODES.N_MR1)
private fun clearShortcuts() {
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
try {
manager.removeAllDynamicShortcuts()
} catch (_: IllegalStateException) {
}
}
private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat.Builder { private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat.Builder {
val icon = runCatching { val icon = runCatching {
val bmp = coil.execute( val bmp = coil.execute(

@ -11,6 +11,12 @@ import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.network.DoHProvider
@ -19,12 +25,6 @@ import org.koitharu.kotatsu.utils.ext.getEnumValue
import org.koitharu.kotatsu.utils.ext.observe import org.koitharu.kotatsu.utils.ext.observe
import org.koitharu.kotatsu.utils.ext.putEnumValue import org.koitharu.kotatsu.utils.ext.putEnumValue
import org.koitharu.kotatsu.utils.ext.toUriOrNull import org.koitharu.kotatsu.utils.ext.toUriOrNull
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
@Singleton @Singleton
class AppSettings @Inject constructor(@ApplicationContext context: Context) { class AppSettings @Inject constructor(@ApplicationContext context: Context) {
@ -65,6 +65,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val readerPageSwitch: Set<String> val readerPageSwitch: Set<String>
get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS) get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS)
val isReaderTapsAdaptive: Boolean
get() = !prefs.getBoolean(KEY_READER_TAPS_LTR, false)
var isTrafficWarningEnabled: Boolean var isTrafficWarningEnabled: Boolean
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true) get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) } set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
@ -73,13 +76,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_ALL_FAVOURITES_VISIBLE, true) get() = prefs.getBoolean(KEY_ALL_FAVOURITES_VISIBLE, true)
set(value) = prefs.edit { putBoolean(KEY_ALL_FAVOURITES_VISIBLE, value) } set(value) = prefs.edit { putBoolean(KEY_ALL_FAVOURITES_VISIBLE, value) }
val isUpdateCheckingEnabled: Boolean
get() = prefs.getBoolean(KEY_APP_UPDATE_AUTO, true)
var lastUpdateCheckTimestamp: Long
get() = prefs.getLong(KEY_APP_UPDATE, 0L)
set(value) = prefs.edit { putLong(KEY_APP_UPDATE, value) }
val isTrackerEnabled: Boolean val isTrackerEnabled: Boolean
get() = prefs.getBoolean(KEY_TRACKER_ENABLED, true) get() = prefs.getBoolean(KEY_TRACKER_ENABLED, true)
@ -141,6 +137,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isExitConfirmationEnabled: Boolean val isExitConfirmationEnabled: Boolean
get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false) get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false)
val isDynamicShortcutsEnabled: Boolean
get() = prefs.getBoolean(KEY_SHORTCUTS, true)
var sourcesOrder: List<String> var sourcesOrder: List<String>
get() = prefs.getString(KEY_SOURCES_ORDER, null) get() = prefs.getString(KEY_SOURCES_ORDER, null)
?.split('|') ?.split('|')
@ -324,10 +323,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_INCOGNITO_MODE = "incognito" const val KEY_INCOGNITO_MODE = "incognito"
const val KEY_SYNC = "sync" const val KEY_SYNC = "sync"
const val KEY_READER_BAR = "reader_bar" const val KEY_READER_BAR = "reader_bar"
const val KEY_SHORTCUTS = "dynamic_shortcuts"
const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_UPDATE_AUTO = "app_update_auto"
const val KEY_APP_TRANSLATION = "about_app_translation" const val KEY_APP_TRANSLATION = "about_app_translation"
private const val NETWORK_NEVER = 0 private const val NETWORK_NEVER = 0

@ -0,0 +1,72 @@
package org.koitharu.kotatsu.core.ui
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.text.HtmlCompat
import androidx.core.text.htmlEncode
import androidx.core.text.parseAsHtml
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.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<DialogMangaErrorBinding>() {
private lateinit var error: Throwable
private lateinit var manga: Manga
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val args = requireArguments()
manga = args.requireParcelable<ParcelableManga>(ARG_MANGA).manga
error = args.requireSerializable(ARG_ERROR)
}
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): DialogMangaErrorBinding {
return DialogMangaErrorBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
with(binding.textViewMessage) {
movementMethod = LinkMovementMethod.getInstance()
text = context.getString(
R.string.manga_error_description_pattern,
this@MangaErrorDialog.error.message?.htmlEncode().orEmpty(),
manga.publicUrl,
).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY)
}
}
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setCancelable(true)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.report) { _, _ ->
dismiss()
error.report(TAG)
}.setTitle(R.string.error_occurred)
}
companion object {
private const val TAG = "MangaErrorDialog"
private const val ARG_ERROR = "error"
private const val ARG_MANGA = "manga"
fun show(fm: FragmentManager, manga: Manga, error: Throwable) = MangaErrorDialog().withArgs(2) {
putParcelable(ARG_MANGA, ParcelableManga(manga, false))
putSerializable(ARG_ERROR, error)
}.show(fm, TAG)
}
}

@ -35,13 +35,13 @@ class ChaptersMenuProvider(
else -> false else -> false
} }
override fun onMenuItemActionExpand(item: MenuItem?): Boolean { override fun onMenuItemActionExpand(item: MenuItem): Boolean {
bottomSheetMediator?.lock() bottomSheetMediator?.lock()
return true return true
} }
override fun onMenuItemActionCollapse(item: MenuItem?): Boolean { override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
(item?.actionView as? SearchView)?.setQuery("", false) (item.actionView as? SearchView)?.setQuery("", false)
viewModel.performChapterSearch(null) viewModel.performChapterSearch(null)
bottomSheetMediator?.unlock() bottomSheetMediator?.unlock()
return true return true

@ -5,8 +5,13 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.os.Bundle import android.os.Bundle
import android.transition.Slide
import android.transition.TransitionManager
import android.view.Gravity
import android.view.Menu import android.view.Menu
import android.view.View import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
@ -16,6 +21,7 @@ import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
@ -27,6 +33,7 @@ import org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.os.ShortcutsUpdater import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.ui.MangaErrorDialog
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
@ -93,6 +100,7 @@ class DetailsActivity :
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved) viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
viewModel.onError.observe(this, ::onError) viewModel.onError.observe(this, ::onError)
viewModel.onShowToast.observe(this) { viewModel.onShowToast.observe(this) {
makeSnackbar(getString(it), Snackbar.LENGTH_SHORT).show()
} }
viewModel.historyInfo.observe(this, ::onHistoryChanged) viewModel.historyInfo.observe(this, ::onHistoryChanged)
viewModel.selectedBranchName.observe(this) { viewModel.selectedBranchName.observe(this) {
@ -158,8 +166,11 @@ class DetailsActivity :
private fun onMangaUpdated(manga: Manga) { private fun onMangaUpdated(manga: Manga) {
title = manga.title title = manga.title
binding.buttonRead.isEnabled = !manga.chapters.isNullOrEmpty() val hasChapters = !manga.chapters.isNullOrEmpty()
binding.buttonRead.isEnabled = hasChapters
invalidateOptionsMenu() invalidateOptionsMenu()
showBottomSheet(manga.chapters != null)
binding.groupHeader?.isVisible = hasChapters
} }
private fun onMangaRemoved(manga: Manga) { private fun onMangaRemoved(manga: Manga) {
@ -172,17 +183,17 @@ class DetailsActivity :
} }
private fun onError(e: Throwable) { private fun onError(e: Throwable) {
val manga = viewModel.manga.value
when { when {
ExceptionResolver.canResolve(e) -> { ExceptionResolver.canResolve(e) -> {
resolveError(e) resolveError(e)
} }
viewModel.manga.value == null -> { manga == null -> {
Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show() Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
finishAfterTransition() finishAfterTransition()
} }
else -> { else -> {
val snackbar = Snackbar.make( val snackbar = makeSnackbar(
binding.containerDetails,
e.getDisplayMessage(resources), e.getDisplayMessage(resources),
if (viewModel.manga.value?.chapters == null) { if (viewModel.manga.value?.chapters == null) {
Snackbar.LENGTH_INDEFINITE Snackbar.LENGTH_INDEFINITE
@ -190,10 +201,9 @@ class DetailsActivity :
Snackbar.LENGTH_LONG Snackbar.LENGTH_LONG
}, },
) )
snackbar.anchorView = binding.headerChapters
if (e.isReportable()) { if (e.isReportable()) {
snackbar.setAction(R.string.report) { snackbar.setAction(R.string.details) {
e.report("DetailsActivity::onError") MangaErrorDialog.show(supportFragmentManager, manga, e)
} }
} }
snackbar.show() snackbar.show()
@ -238,8 +248,7 @@ class DetailsActivity :
fun showChapterMissingDialog(chapterId: Long) { fun showChapterMissingDialog(chapterId: Long) {
val remoteManga = viewModel.getRemoteManga() val remoteManga = viewModel.getRemoteManga()
if (remoteManga == null) { if (remoteManga == null) {
val snackbar = Snackbar.make(binding.containerDetails, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT) val snackbar = makeSnackbar(getString(R.string.chapter_is_missing), Snackbar.LENGTH_SHORT)
snackbar.anchorView = binding.headerChapters
snackbar.show() snackbar.show()
return return
} }
@ -291,6 +300,24 @@ class DetailsActivity :
private fun isTabletLayout() = binding.layoutBottom == null private fun isTabletLayout() = binding.layoutBottom == null
private fun showBottomSheet(isVisible: Boolean) {
val view = binding.layoutBottom ?: return
if (view.isVisible == isVisible) return
val transition = Slide(Gravity.BOTTOM)
transition.addTarget(view)
transition.interpolator = AccelerateDecelerateInterpolator()
TransitionManager.beginDelayedTransition(binding.root as ViewGroup, transition)
view.isVisible = isVisible
}
private fun makeSnackbar(text: CharSequence, @BaseTransientBottomBar.Duration duration: Int): Snackbar {
val sb = Snackbar.make(binding.containerDetails, text, duration)
if (binding.layoutBottom?.isVisible == true) {
sb.anchorView = binding.headerChapters
}
return sb
}
companion object { companion object {
fun newIntent(context: Context, manga: Manga): Intent { fun newIntent(context: Context, manga: Manga): Intent {

@ -70,6 +70,7 @@ class DetailsFragment :
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.textViewAuthor.setOnClickListener(this) binding.textViewAuthor.setOnClickListener(this)
binding.imageViewCover.setOnClickListener(this) binding.imageViewCover.setOnClickListener(this)
binding.infoLayout.textViewSource.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance() binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.chipsTags.onChipClickListener = this binding.chipsTags.onChipClickListener = this
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated) viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
@ -228,6 +229,14 @@ class DetailsFragment :
), ),
) )
} }
R.id.textView_source -> {
startActivity(
MangaListActivity.newIntent(
context = v.context,
source = manga.source,
),
)
}
R.id.imageView_cover -> { R.id.imageView_cover -> {
startActivity( startActivity(
ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }), ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }),

@ -26,6 +26,7 @@ import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.assistedViewModels import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getSerializableCompat
@AndroidEntryPoint @AndroidEntryPoint
class FavouritesCategoryEditActivity : class FavouritesCategoryEditActivity :
@ -70,8 +71,8 @@ class FavouritesCategoryEditActivity :
override fun onRestoreInstanceState(savedInstanceState: Bundle) { override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState) super.onRestoreInstanceState(savedInstanceState)
val order = savedInstanceState.getSerializable(KEY_SORT_ORDER) val order = savedInstanceState.getSerializableCompat<SortOrder>(KEY_SORT_ORDER)
if (order != null && order is SortOrder) { if (order != null) {
selectedSortOrder = order selectedSortOrder = order
} }
} }

@ -26,6 +26,7 @@ import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
class FavouritesListViewModel @AssistedInject constructor( class FavouritesListViewModel @AssistedInject constructor(
@ -53,7 +54,7 @@ class FavouritesListViewModel @AssistedInject constructor(
} else { } else {
repository.observeAll(categoryId) repository.observeAll(categoryId)
}, },
createListModeFlow() createListModeFlow(),
) { list, mode -> ) { list, mode ->
when { when {
list.isEmpty() -> listOf( list.isEmpty() -> listOf(
@ -66,13 +67,13 @@ class FavouritesListViewModel @AssistedInject constructor(
R.string.favourites_category_empty R.string.favourites_category_empty
}, },
actionStringRes = 0, actionStringRes = 0,
) ),
) )
else -> list.toUi(mode, this) else -> list.toUi(mode, this)
} }
}.catch { }.catch {
emit(listOf(it.toErrorState(canRetry = false))) emit(listOf(it.toErrorState(canRetry = false)))
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
init { init {
if (categoryId != NO_ID) { if (categoryId != NO_ID) {

@ -23,7 +23,7 @@ import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.daysDiff import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.onFirst import org.koitharu.kotatsu.utils.ext.onFirst
@ -60,8 +60,8 @@ class HistoryListViewModel @Inject constructor(
}.onFirst { }.onFirst {
loadingCounter.decrement() loadingCounter.decrement()
}.catch { }.catch {
it.toErrorState(canRetry = false) emit(listOf(it.toErrorState(canRetry = false)))
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
override fun onRefresh() = Unit override fun onRefresh() = Unit

@ -56,17 +56,17 @@ class ReadingProgressView @JvmOverloads constructor(
getProgressDrawable().progress = p getProgressDrawable().progress = p
} }
override fun onAnimationStart(animation: Animator?) = Unit override fun onAnimationStart(animation: Animator) = Unit
override fun onAnimationEnd(animation: Animator?) { override fun onAnimationEnd(animation: Animator) {
if (percentAnimator === animation) { if (percentAnimator === animation) {
percentAnimator = null percentAnimator = null
} }
} }
override fun onAnimationCancel(animation: Animator?) = Unit override fun onAnimationCancel(animation: Animator) = Unit
override fun onAnimationRepeat(animation: Animator?) = Unit override fun onAnimationRepeat(animation: Animator) = Unit
fun setPercent(value: Float, animate: Boolean) { fun setPercent(value: Float, animate: Boolean) {
val currentDrawable = peekProgressDrawable() val currentDrawable = peekProgressDrawable()

@ -27,7 +27,7 @@ import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.daysDiff import org.koitharu.kotatsu.utils.ext.daysDiff
private const val HISTORY_MAX_SEGMENTS = 2 private const val HISTORY_MAX_SEGMENTS = 2
@ -49,8 +49,8 @@ class LibraryViewModel @Inject constructor(
) { history, favourites -> ) { history, favourites ->
mapList(history, favourites) mapList(history, favourites)
}.catch { e -> }.catch { e ->
e.toErrorState(canRetry = false) emit(listOf(e.toErrorState(canRetry = false)))
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
override suspend fun getCounter(mangaId: Long): Int { override suspend fun getCounter(mangaId: Long): Int {
return trackingRepository.getNewChaptersCount(mangaId) return trackingRepository.getNewChaptersCount(mangaId)

@ -6,6 +6,7 @@ import android.view.View
import android.widget.TextView import android.widget.TextView
import androidx.annotation.StyleRes import androidx.annotation.StyleRes
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.widget.TextViewCompat
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -46,12 +47,12 @@ class ItemSizeResolver(resources: Resources, private val settings: AppSettings)
} }
} }
override fun onViewAttachedToWindow(v: View?) { override fun onViewAttachedToWindow(v: View) {
settings.subscribe(this) settings.subscribe(this)
update() update()
} }
override fun onViewDetachedFromWindow(v: View?) { override fun onViewDetachedFromWindow(v: View) {
settings.unsubscribe(this) settings.unsubscribe(this)
} }
@ -77,7 +78,7 @@ class ItemSizeResolver(resources: Resources, private val settings: AppSettings)
} }
if (textAppearanceResId != prevTextAppearance) { if (textAppearanceResId != prevTextAppearance) {
prevTextAppearance = textAppearanceResId prevTextAppearance = textAppearanceResId
setTextAppearance(textAppearanceResId) TextViewCompat.setTextAppearance(this, textAppearanceResId)
requestLayout() requestLayout()
} }
} }

@ -9,6 +9,7 @@ import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.base.ui.widgets.CheckableButtonGroup import org.koitharu.kotatsu.base.ui.widgets.CheckableButtonGroup
@ -17,7 +18,6 @@ import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.databinding.DialogListModeBinding import org.koitharu.kotatsu.databinding.DialogListModeBinding
import org.koitharu.kotatsu.utils.ext.setValueRounded import org.koitharu.kotatsu.utils.ext.setValueRounded
import org.koitharu.kotatsu.utils.progress.IntPercentLabelFormatter import org.koitharu.kotatsu.utils.progress.IntPercentLabelFormatter
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ListModeSelectDialog : class ListModeSelectDialog :
@ -33,8 +33,9 @@ class ListModeSelectDialog :
container: ViewGroup?, container: ViewGroup?,
) = DialogListModeBinding.inflate(inflater, container, false) ) = DialogListModeBinding.inflate(inflater, container, false)
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
builder.setTitle(R.string.list_mode) return super.onBuildDialog(builder)
.setTitle(R.string.list_mode)
.setPositiveButton(R.string.done, null) .setPositiveButton(R.string.done, null)
.setCancelable(true) .setCancelable(true)
} }

@ -1,9 +1,11 @@
package org.koitharu.kotatsu.list.ui.filter package org.koitharu.kotatsu.list.ui.filter
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
class FilterAdapter( class FilterAdapter(
listener: OnFilterChangedListener, listener: OnFilterChangedListener,
listListener: ListListener<FilterItem>,
) : AsyncListDifferDelegationAdapter<FilterItem>( ) : AsyncListDifferDelegationAdapter<FilterItem>(
FilterDiffCallback(), FilterDiffCallback(),
filterSortDelegate(listener), filterSortDelegate(listener),
@ -11,4 +13,9 @@ class FilterAdapter(
filterHeaderDelegate(), filterHeaderDelegate(),
filterLoadingDelegate(), filterLoadingDelegate(),
filterErrorDelegate(), filterErrorDelegate(),
) ) {
init {
differ.addListListener(listListener)
}
}

@ -6,18 +6,21 @@ import android.os.Bundle
import android.view.* import android.view.*
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import dagger.hilt.android.AndroidEntryPoint import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.LinearLayoutManager
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.databinding.SheetFilterBinding import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
import org.koitharu.kotatsu.utils.ext.isScrolledToTop
import org.koitharu.kotatsu.utils.ext.parentFragmentViewModels import org.koitharu.kotatsu.utils.ext.parentFragmentViewModels
class FilterBottomSheet : class FilterBottomSheet :
BaseBottomSheet<SheetFilterBinding>(), BaseBottomSheet<SheetFilterBinding>(),
MenuItem.OnActionExpandListener, MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener, SearchView.OnQueryTextListener,
DialogInterface.OnKeyListener { DialogInterface.OnKeyListener,
AsyncListDiffer.ListListener<FilterItem> {
private val viewModel by parentFragmentViewModels<RemoteListViewModel>() private val viewModel by parentFragmentViewModels<RemoteListViewModel>()
@ -33,13 +36,13 @@ class FilterBottomSheet :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val adapter = FilterAdapter(viewModel) val adapter = FilterAdapter(viewModel, this)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
viewModel.filterItems.observe(viewLifecycleOwner, adapter::setItems) viewModel.filterItems.observe(viewLifecycleOwner, adapter::setItems)
initOptionsMenu() initOptionsMenu()
} }
override fun onMenuItemActionExpand(item: MenuItem?): Boolean { override fun onMenuItemActionExpand(item: MenuItem): Boolean {
setExpanded(isExpanded = true, isLocked = true) setExpanded(isExpanded = true, isLocked = true)
return true return true
} }
@ -71,6 +74,12 @@ class FilterBottomSheet :
return false return false
} }
override fun onCurrentListChanged(previousList: MutableList<FilterItem>, currentList: MutableList<FilterItem>) {
if (currentList.size > previousList.size && view != null) {
(binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(0, 0)
}
}
private fun initOptionsMenu() { private fun initOptionsMenu() {
binding.headerBar.toolbar.inflateMenu(R.menu.opt_filter) binding.headerBar.toolbar.inflateMenu(R.menu.opt_filter)
val searchMenuItem = binding.headerBar.toolbar.menu.findItem(R.id.action_search) val searchMenuItem = binding.headerBar.toolbar.menu.findItem(R.id.action_search)

@ -63,6 +63,10 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
x.tags.containsAll(tags) x.tags.containsAll(tags)
} }
} }
when (sortOrder) {
SortOrder.ALPHABETICAL -> list.sortBy { it.title }
SortOrder.RATING -> list.sortBy { it.rating }
}
return list return list
} }
@ -250,7 +254,7 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
} }
} }
override val sortOrders = setOf(SortOrder.ALPHABETICAL) override val sortOrders = setOf(SortOrder.ALPHABETICAL, SortOrder.RATING)
override suspend fun getPageUrl(page: MangaPage) = page.url override suspend fun getPageUrl(page: MangaPage) = page.url

@ -27,8 +27,9 @@ class ImportDialogFragment : AlertDialogFragment<DialogImportBinding>(), View.On
return DialogImportBinding.inflate(inflater, container, false) return DialogImportBinding.inflate(inflater, container, false)
} }
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
builder.setTitle(R.string._import) return super.onBuildDialog(builder)
.setTitle(R.string._import)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setCancelable(true) .setCancelable(true)
} }

@ -1,5 +1,8 @@
package org.koitharu.kotatsu.main.ui package org.koitharu.kotatsu.main.ui
import android.Manifest
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.SparseIntArray import android.util.SparseIntArray
import android.view.MenuItem import android.view.MenuItem
@ -7,6 +10,7 @@ import android.view.View
import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCallback
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.core.app.ActivityCompat
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
@ -24,7 +28,6 @@ import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.widgets.SlidingBottomNavigationView import org.koitharu.kotatsu.base.ui.widgets.SlidingBottomNavigationView
@ -291,12 +294,11 @@ class MainActivity :
TrackWorker.setup(applicationContext) TrackWorker.setup(applicationContext)
SuggestionsWorker.setup(applicationContext) SuggestionsWorker.setup(applicationContext)
} }
requestNotificationsPermission()
when { when {
!settings.isSourcesSelected -> OnboardDialogFragment.showWelcome(supportFragmentManager) !settings.isSourcesSelected -> OnboardDialogFragment.showWelcome(supportFragmentManager)
settings.newSources.isNotEmpty() -> NewSourcesDialogFragment.show(supportFragmentManager) settings.newSources.isNotEmpty() -> NewSourcesDialogFragment.show(supportFragmentManager)
} }
yield()
// TODO get<SyncController>().requestFullSyncAndGc(get())
} }
} }
@ -347,6 +349,15 @@ class MainActivity :
showNav(!isOpened) showNav(!isOpened)
} }
private fun requestNotificationsPermission() {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
}
}
private inner class VoiceInputCallback : ActivityResultCallback<String?> { private inner class VoiceInputCallback : ActivityResultCallback<String?> {
override fun onActivityResult(result: String?) { override fun onActivityResult(result: String?) {

@ -4,26 +4,28 @@ import android.util.SparseIntArray
import androidx.core.util.set import androidx.core.util.set
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.github.AppUpdateRepository import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MainViewModel @Inject constructor( class MainViewModel @Inject constructor(
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val settings: AppSettings,
private val appUpdateRepository: AppUpdateRepository, private val appUpdateRepository: AppUpdateRepository,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
syncController: SyncController,
database: MangaDatabase,
) : BaseViewModel() { ) : BaseViewModel() {
val onOpenReader = SingleLiveEvent<Manga>() val onOpenReader = SingleLiveEvent<Manga>()
@ -43,9 +45,12 @@ class MainViewModel @Inject constructor(
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, SparseIntArray(0)) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, SparseIntArray(0))
init { init {
launchJob(Dispatchers.Default) { launchJob {
appUpdateRepository.fetchUpdate() appUpdateRepository.fetchUpdate()
} }
launchJob {
syncController.requestFullSyncAndGc(database)
}
} }
fun openLastReader() { fun openLastReader() {

@ -6,6 +6,7 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import org.acra.dialog.CrashReportDialog
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@Singleton @Singleton
@ -14,7 +15,7 @@ class AppProtectHelper @Inject constructor(private val settings: AppSettings) :
private var isUnlocked = settings.appPassword.isNullOrEmpty() private var isUnlocked = settings.appPassword.isNullOrEmpty()
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
if (activity !is ProtectActivity && !isUnlocked) { if (!isUnlocked && activity !is ProtectActivity && activity !is CrashReportDialog) {
val sourceIntent = Intent(activity, activity.javaClass) val sourceIntent = Intent(activity, activity.javaClass)
activity.intent?.let { activity.intent?.let {
sourceIntent.putExtras(it) sourceIntent.putExtras(it)

@ -22,6 +22,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityProtectBinding import org.koitharu.kotatsu.databinding.ActivityProtectBinding
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
@AndroidEntryPoint @AndroidEntryPoint
class ProtectActivity : class ProtectActivity :
@ -44,7 +45,7 @@ class ProtectActivity :
viewModel.onError.observe(this, this::onError) viewModel.onError.observe(this, this::onError)
viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.onUnlockSuccess.observe(this) { viewModel.onUnlockSuccess.observe(this) {
val intent = intent.getParcelableExtra<Intent>(EXTRA_INTENT) val intent = intent.getParcelableExtraCompat<Intent>(EXTRA_INTENT)
startActivity(intent) startActivity(intent)
finishAfterTransition() finishAfterTransition()
} }

@ -6,6 +6,8 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlin.math.roundToInt
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
@ -17,9 +19,8 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback
import org.koitharu.kotatsu.utils.ext.getParcelableCompat
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
import kotlin.math.roundToInt
@AndroidEntryPoint @AndroidEntryPoint
class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemClickListener<ChapterListItem> { class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemClickListener<ChapterListItem> {
@ -33,7 +34,7 @@ class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemC
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val chapters = arguments?.getParcelable<ParcelableMangaChapters>(ARG_CHAPTERS)?.chapters val chapters = arguments?.getParcelableCompat<ParcelableMangaChapters>(ARG_CHAPTERS)?.chapters
if (chapters.isNullOrEmpty()) { if (chapters.isNullOrEmpty()) {
dismissAllowingStateLoss() dismissAllowingStateLoss()
return return

@ -40,6 +40,7 @@ import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.utils.GridTouchHelper import org.koitharu.kotatsu.utils.GridTouchHelper
import org.koitharu.kotatsu.utils.IdlingDetector
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
@ -51,15 +52,18 @@ class ReaderActivity :
OnPageSelectListener, OnPageSelectListener,
ReaderConfigBottomSheet.Callback, ReaderConfigBottomSheet.Callback,
ReaderControlDelegate.OnInteractionListener, ReaderControlDelegate.OnInteractionListener,
OnApplyWindowInsetsListener { OnApplyWindowInsetsListener,
IdlingDetector.Callback {
@Inject @Inject
lateinit var viewModelFactory: ReaderViewModel.Factory lateinit var viewModelFactory: ReaderViewModel.Factory
private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this)
val viewModel by assistedViewModels { val viewModel by assistedViewModels {
viewModelFactory.create( viewModelFactory.create(
intent = MangaIntent(intent), intent = MangaIntent(intent),
initialState = intent?.getParcelableExtra(EXTRA_STATE), initialState = intent?.getParcelableExtraCompat(EXTRA_STATE),
preselectedBranch = intent?.getStringExtra(EXTRA_BRANCH), preselectedBranch = intent?.getStringExtra(EXTRA_BRANCH),
) )
} }
@ -70,6 +74,9 @@ class ReaderActivity :
pageSwitchTimer.delaySec = value pageSwitchTimer.delaySec = value
} }
override val readerMode: ReaderMode?
get() = readerManager.currentMode
private lateinit var pageSwitchTimer: PageSwitchTimer private lateinit var pageSwitchTimer: PageSwitchTimer
private lateinit var touchHelper: GridTouchHelper private lateinit var touchHelper: GridTouchHelper
private lateinit var controlDelegate: ReaderControlDelegate private lateinit var controlDelegate: ReaderControlDelegate
@ -84,11 +91,12 @@ class ReaderActivity :
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
touchHelper = GridTouchHelper(this, this) touchHelper = GridTouchHelper(this, this)
pageSwitchTimer = PageSwitchTimer(this, this) pageSwitchTimer = PageSwitchTimer(this, this)
controlDelegate = ReaderControlDelegate(lifecycleScope, settings, this) controlDelegate = ReaderControlDelegate(settings, this, this)
binding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected) binding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected)
binding.slider.setLabelFormatter(PageLabelFormatter()) binding.slider.setLabelFormatter(PageLabelFormatter())
ReaderSliderListener(this, viewModel).attachToSlider(binding.slider) ReaderSliderListener(this, viewModel).attachToSlider(binding.slider)
insetsDelegate.interceptingWindowInsetsListener = this insetsDelegate.interceptingWindowInsetsListener = this
idlingDetector.bindToLifecycle(this)
viewModel.onError.observe(this, this::onError) viewModel.onError.observe(this, this::onError)
viewModel.readerMode.observe(this, this::onInitReader) viewModel.readerMode.observe(this, this::onInitReader)
@ -111,6 +119,11 @@ class ReaderActivity :
override fun onUserInteraction() { override fun onUserInteraction() {
super.onUserInteraction() super.onUserInteraction()
pageSwitchTimer.onUserInteraction() pageSwitchTimer.onUserInteraction()
idlingDetector.onUserInteraction()
}
override fun onIdle() {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
} }
private fun onInitReader(mode: ReaderMode) { private fun onInitReader(mode: ReaderMode) {

@ -15,14 +15,15 @@ import org.koitharu.kotatsu.databinding.DialogReaderConfigBinding
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
@Deprecated("Not in use") @Deprecated("Not in use")
class ReaderConfigDialog : AlertDialogFragment<DialogReaderConfigBinding>(), class ReaderConfigDialog :
AlertDialogFragment<DialogReaderConfigBinding>(),
CheckableButtonGroup.OnCheckedChangeListener { CheckableButtonGroup.OnCheckedChangeListener {
private lateinit var mode: ReaderMode private lateinit var mode: ReaderMode
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup? container: ViewGroup?,
) = DialogReaderConfigBinding.inflate(inflater, container, false) ) = DialogReaderConfigBinding.inflate(inflater, container, false)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -32,8 +33,9 @@ class ReaderConfigDialog : AlertDialogFragment<DialogReaderConfigBinding>(),
?: ReaderMode.STANDARD ?: ReaderMode.STANDARD
} }
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
builder.setTitle(R.string.read_mode) return super.onBuildDialog(builder)
.setTitle(R.string.read_mode)
.setPositiveButton(R.string.done, null) .setPositiveButton(R.string.done, null)
.setCancelable(true) .setCancelable(true)
} }
@ -48,8 +50,10 @@ class ReaderConfigDialog : AlertDialogFragment<DialogReaderConfigBinding>(),
} }
override fun onDismiss(dialog: DialogInterface) { override fun onDismiss(dialog: DialogInterface) {
((parentFragment as? Callback) (
?: (activity as? Callback))?.onReaderModeChanged(mode) (parentFragment as? Callback)
?: (activity as? Callback)
)?.onReaderModeChanged(mode)
super.onDismiss(dialog) super.onDismiss(dialog)
} }

@ -1,33 +1,39 @@
package org.koitharu.kotatsu.reader.ui package org.koitharu.kotatsu.reader.ui
import android.content.SharedPreferences
import android.view.KeyEvent import android.view.KeyEvent
import android.view.SoundEffectConstants import android.view.SoundEffectConstants
import android.view.View import android.view.View
import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.DefaultLifecycleObserver
import kotlinx.coroutines.Dispatchers import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.utils.GridTouchHelper import org.koitharu.kotatsu.utils.GridTouchHelper
class ReaderControlDelegate( class ReaderControlDelegate(
scope: LifecycleCoroutineScope, private val settings: AppSettings,
settings: AppSettings,
private val listener: OnInteractionListener, private val listener: OnInteractionListener,
) { owner: LifecycleOwner,
) : DefaultLifecycleObserver, SharedPreferences.OnSharedPreferenceChangeListener {
private var isTapSwitchEnabled: Boolean = true private var isTapSwitchEnabled: Boolean = true
private var isVolumeKeysSwitchEnabled: Boolean = false private var isVolumeKeysSwitchEnabled: Boolean = false
private var isReaderTapsAdaptive: Boolean = true
init { init {
settings.observeAsFlow(AppSettings.KEY_READER_SWITCHERS) { readerPageSwitch } owner.lifecycle.addObserver(this)
.flowOn(Dispatchers.Default) settings.subscribe(this)
.onEach { updateSettings()
isTapSwitchEnabled = AppSettings.PAGE_SWITCH_TAPS in it }
isVolumeKeysSwitchEnabled = AppSettings.PAGE_SWITCH_VOLUME_KEYS in it
}.launchIn(scope) override fun onDestroy(owner: LifecycleOwner) {
settings.unsubscribe(this)
owner.lifecycle.removeObserver(this)
super.onDestroy(owner)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
updateSettings()
} }
fun onGridTouch(area: Int, view: View) { fun onGridTouch(area: Int, view: View) {
@ -41,7 +47,7 @@ class ReaderControlDelegate(
view.playSoundEffect(SoundEffectConstants.NAVIGATION_UP) view.playSoundEffect(SoundEffectConstants.NAVIGATION_UP)
} }
GridTouchHelper.AREA_LEFT -> if (isTapSwitchEnabled) { GridTouchHelper.AREA_LEFT -> if (isTapSwitchEnabled) {
listener.switchPageBy(-1) listener.switchPageBy(if (isReaderTapsReversed()) 1 else -1)
view.playSoundEffect(SoundEffectConstants.NAVIGATION_LEFT) view.playSoundEffect(SoundEffectConstants.NAVIGATION_LEFT)
} }
GridTouchHelper.AREA_BOTTOM -> if (isTapSwitchEnabled) { GridTouchHelper.AREA_BOTTOM -> if (isTapSwitchEnabled) {
@ -49,7 +55,7 @@ class ReaderControlDelegate(
view.playSoundEffect(SoundEffectConstants.NAVIGATION_DOWN) view.playSoundEffect(SoundEffectConstants.NAVIGATION_DOWN)
} }
GridTouchHelper.AREA_RIGHT -> if (isTapSwitchEnabled) { GridTouchHelper.AREA_RIGHT -> if (isTapSwitchEnabled) {
listener.switchPageBy(1) listener.switchPageBy(if (isReaderTapsReversed()) -1 else 1)
view.playSoundEffect(SoundEffectConstants.NAVIGATION_RIGHT) view.playSoundEffect(SoundEffectConstants.NAVIGATION_RIGHT)
} }
} }
@ -72,19 +78,25 @@ class ReaderControlDelegate(
KeyEvent.KEYCODE_PAGE_DOWN, KeyEvent.KEYCODE_PAGE_DOWN,
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN,
KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_RIGHT,
-> { -> {
listener.switchPageBy(1) listener.switchPageBy(1)
true true
} }
KeyEvent.KEYCODE_DPAD_RIGHT -> {
listener.switchPageBy(if (isReaderTapsReversed()) -1 else 1)
true
}
KeyEvent.KEYCODE_PAGE_UP, KeyEvent.KEYCODE_PAGE_UP,
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP,
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_LEFT,
-> { -> {
listener.switchPageBy(-1) listener.switchPageBy(-1)
true true
} }
KeyEvent.KEYCODE_DPAD_LEFT -> {
listener.switchPageBy(if (isReaderTapsReversed()) 1 else -1)
true
}
KeyEvent.KEYCODE_DPAD_CENTER -> { KeyEvent.KEYCODE_DPAD_CENTER -> {
listener.toggleUiVisibility() listener.toggleUiVisibility()
true true
@ -99,8 +111,21 @@ class ReaderControlDelegate(
) )
} }
private fun updateSettings() {
val switch = settings.readerPageSwitch
isTapSwitchEnabled = AppSettings.PAGE_SWITCH_TAPS in switch
isVolumeKeysSwitchEnabled = AppSettings.PAGE_SWITCH_VOLUME_KEYS in switch
isReaderTapsAdaptive = settings.isReaderTapsAdaptive
}
private fun isReaderTapsReversed(): Boolean {
return isReaderTapsAdaptive && listener.readerMode == ReaderMode.REVERSED
}
interface OnInteractionListener { interface OnInteractionListener {
val readerMode: ReaderMode?
fun switchPageBy(delta: Int) fun switchPageBy(delta: Int)
fun toggleUiVisibility() fun toggleUiVisibility()

@ -8,17 +8,19 @@ import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Rect import android.graphics.Rect
import android.icu.text.SimpleDateFormat
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
import java.text.SimpleDateFormat
import java.util.* import java.util.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.utils.ext.getThemeColor import org.koitharu.kotatsu.utils.ext.getThemeColor
import org.koitharu.kotatsu.utils.ext.measureDimension
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.resolveDp import org.koitharu.kotatsu.utils.ext.resolveDp
class ReaderInfoBarView @JvmOverloads constructor( class ReaderInfoBarView @JvmOverloads constructor(
@ -29,23 +31,46 @@ class ReaderInfoBarView @JvmOverloads constructor(
private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val textBounds = Rect() private val textBounds = Rect()
private val inset = context.resources.resolveDp(2f)
private val timeFormat = SimpleDateFormat.getTimeInstance(SimpleDateFormat.SHORT) private val timeFormat = SimpleDateFormat.getTimeInstance(SimpleDateFormat.SHORT)
private val timeReceiver = TimeReceiver() private val timeReceiver = TimeReceiver()
private var insetLeft: Int = 0
private var insetRight: Int = 0
private var insetTop: Int = 0
private val colorText = ColorUtils.setAlphaComponent(
context.getThemeColor(materialR.attr.colorOnSurface, Color.BLACK),
200,
)
private val colorOutline = ColorUtils.setAlphaComponent(
context.getThemeColor(materialR.attr.colorSurface, Color.WHITE),
200,
)
private var timeText = timeFormat.format(Date()) private var timeText = timeFormat.format(Date())
private var text: String = "" private var text: String = ""
private val innerHeight private val innerHeight
get() = height - inset - inset - paddingTop - paddingBottom get() = height - paddingTop - paddingBottom - insetTop
private val innerWidth private val innerWidth
get() = width - inset - inset - paddingLeft - paddingRight get() = width - paddingLeft - paddingRight - insetLeft - insetRight
init { init {
paint.color = ColorUtils.setAlphaComponent( paint.strokeWidth = context.resources.resolveDp(2f)
context.getThemeColor(materialR.attr.colorOnSurface, Color.BLACK), val insetCorner = getSystemUiDimensionOffset("rounded_corner_content_padding")
160, val insetStart = getSystemUiDimensionOffset("status_bar_padding_start") + insetCorner
val insetEnd = getSystemUiDimensionOffset("status_bar_padding_end") + insetCorner
val isRtl = layoutDirection == LAYOUT_DIRECTION_RTL
insetLeft = if (isRtl) insetEnd else insetStart
insetRight = if (isRtl) insetStart else insetEnd
insetTop = minOf(insetLeft, insetRight)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val desiredWidth = suggestedMinimumWidth + paddingLeft + paddingRight + insetLeft + insetRight
val desiredHeight = suggestedMinimumHeight + paddingTop + paddingBottom + insetTop
setMeasuredDimension(
measureDimension(desiredWidth, widthMeasureSpec),
measureDimension(desiredHeight, heightMeasureSpec),
) )
} }
@ -53,9 +78,9 @@ class ReaderInfoBarView @JvmOverloads constructor(
super.onDraw(canvas) super.onDraw(canvas)
val ty = innerHeight / 2f + textBounds.height() / 2f - textBounds.bottom val ty = innerHeight / 2f + textBounds.height() / 2f - textBounds.bottom
paint.textAlign = Paint.Align.LEFT paint.textAlign = Paint.Align.LEFT
canvas.drawText(text, paddingLeft + inset, paddingTop + inset + ty, paint) canvas.drawTextOutline(text, (paddingLeft + insetLeft).toFloat(), paddingTop + insetTop + ty)
paint.textAlign = Paint.Align.RIGHT paint.textAlign = Paint.Align.RIGHT
canvas.drawText(timeText, width - paddingRight - inset, paddingTop + inset + ty, paint) canvas.drawTextOutline(timeText, (width - paddingRight - insetRight).toFloat(), paddingTop + insetTop + ty)
} }
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
@ -103,6 +128,15 @@ class ReaderInfoBarView @JvmOverloads constructor(
paint.getTextBounds(str, 0, str.length, textBounds) paint.getTextBounds(str, 0, str.length, textBounds)
} }
private fun Canvas.drawTextOutline(text: String, x: Float, y: Float) {
paint.color = colorOutline
paint.style = Paint.Style.STROKE
drawText(text, x, y, paint)
paint.color = colorText
paint.style = Paint.Style.FILL
drawText(text, x, y, paint)
}
private inner class TimeReceiver : BroadcastReceiver() { private inner class TimeReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
@ -110,4 +144,13 @@ class ReaderInfoBarView @JvmOverloads constructor(
invalidate() invalidate()
} }
} }
private fun getSystemUiDimensionOffset(name: String): Int = runCatching {
val manager = context.packageManager
val resources = manager.getResourcesForApplication("com.android.systemui")
val resId = resources.getIdentifier(name, "dimen", "com.android.systemui")
resources.getDimensionPixelOffset(resId)
}.onFailure {
it.printStackTraceDebug()
}.getOrDefault(0)
} }

@ -8,6 +8,7 @@ import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.ReaderViewModel import org.koitharu.kotatsu.reader.ui.ReaderViewModel
import org.koitharu.kotatsu.utils.ext.getParcelableCompat
private const val KEY_STATE = "state" private const val KEY_STATE = "state"
@ -18,7 +19,7 @@ abstract class BaseReader<B : ViewBinding> : BaseFragment<B>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
var restoredState = savedInstanceState?.getParcelable<ReaderState?>(KEY_STATE) var restoredState = savedInstanceState?.getParcelableCompat<ReaderState>(KEY_STATE)
viewModel.content.observe(viewLifecycleOwner) { viewModel.content.observe(viewLifecycleOwner) {
onPagesChanged(it.pages, restoredState ?: it.state) onPagesChanged(it.pages, restoredState ?: it.state)

@ -4,12 +4,12 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.utils.ext.resetTransformations import org.koitharu.kotatsu.utils.ext.resetTransformations
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Suppress("LeakingThis") @Suppress("LeakingThis")
abstract class BaseReaderAdapter<H : BasePageHolder<*>>( abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
@ -45,7 +45,7 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
final override fun onCreateViewHolder( final override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
viewType: Int viewType: Int,
): H = onCreateViewHolder(parent, loader, settings, exceptionResolver) ): H = onCreateViewHolder(parent, loader, settings, exceptionResolver)
suspend fun setItems(items: List<ReaderPage>) = suspendCoroutine<Unit> { cont -> suspend fun setItems(items: List<ReaderPage>) = suspendCoroutine<Unit> { cont ->
@ -58,7 +58,7 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
parent: ViewGroup, parent: ViewGroup,
loader: PageLoader, loader: PageLoader,
settings: AppSettings, settings: AppSettings,
exceptionResolver: ExceptionResolver exceptionResolver: ExceptionResolver,
): H ): H
private class DiffCallback : DiffUtil.ItemCallback<ReaderPage>() { private class DiffCallback : DiffUtil.ItemCallback<ReaderPage>() {
@ -70,6 +70,5 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
override fun areContentsTheSame(oldItem: ReaderPage, newItem: ReaderPage): Boolean { override fun areContentsTheSame(oldItem: ReaderPage, newItem: ReaderPage): Boolean {
return oldItem == newItem return oldItem == newItem
} }
} }
} }

@ -69,7 +69,7 @@ open class PageHolder(
override fun onImageShowing(zoom: ZoomMode) { override fun onImageShowing(zoom: ZoomMode) {
binding.ssiv.maxScale = 2f * maxOf( binding.ssiv.maxScale = 2f * maxOf(
binding.ssiv.width / binding.ssiv.sWidth.toFloat(), binding.ssiv.width / binding.ssiv.sWidth.toFloat(),
binding.ssiv.height / binding.ssiv.sHeight.toFloat() binding.ssiv.height / binding.ssiv.sHeight.toFloat(),
) )
when (zoom) { when (zoom) {
ZoomMode.FIT_CENTER -> { ZoomMode.FIT_CENTER -> {
@ -81,7 +81,7 @@ open class PageHolder(
binding.ssiv.minScale = binding.ssiv.height / binding.ssiv.sHeight.toFloat() binding.ssiv.minScale = binding.ssiv.height / binding.ssiv.sHeight.toFloat()
binding.ssiv.setScaleAndCenter( binding.ssiv.setScaleAndCenter(
binding.ssiv.minScale, binding.ssiv.minScale,
PointF(0f, binding.ssiv.sHeight / 2f) PointF(0f, binding.ssiv.sHeight / 2f),
) )
} }
ZoomMode.FIT_WIDTH -> { ZoomMode.FIT_WIDTH -> {
@ -89,14 +89,14 @@ open class PageHolder(
binding.ssiv.minScale = binding.ssiv.width / binding.ssiv.sWidth.toFloat() binding.ssiv.minScale = binding.ssiv.width / binding.ssiv.sWidth.toFloat()
binding.ssiv.setScaleAndCenter( binding.ssiv.setScaleAndCenter(
binding.ssiv.minScale, binding.ssiv.minScale,
PointF(binding.ssiv.sWidth / 2f, 0f) PointF(binding.ssiv.sWidth / 2f, 0f),
) )
} }
ZoomMode.KEEP_START -> { ZoomMode.KEEP_START -> {
binding.ssiv.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE) binding.ssiv.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE)
binding.ssiv.setScaleAndCenter( binding.ssiv.setScaleAndCenter(
binding.ssiv.maxScale, binding.ssiv.maxScale,
PointF(0f, 0f) PointF(0f, 0f),
) )
} }
} }
@ -115,7 +115,7 @@ open class PageHolder(
override fun onError(e: Throwable) { override fun onError(e: Throwable) {
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources) bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
bindingInfo.buttonRetry.setText( bindingInfo.buttonRetry.setText(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again } ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
) )
bindingInfo.layoutError.isVisible = true bindingInfo.layoutError.isVisible = true
bindingInfo.progressBar.hideCompat() bindingInfo.progressBar.hideCompat()

@ -24,6 +24,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter 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.viewLifecycleScope
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
@ -52,7 +53,7 @@ class PagesThumbnailsSheet :
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val pages = arguments?.getParcelable<ParcelableMangaPages>(ARG_PAGES)?.pages val pages = arguments?.getParcelableCompat<ParcelableMangaPages>(ARG_PAGES)?.pages
if (pages.isNullOrEmpty()) { if (pages.isNullOrEmpty()) {
dismissAllowingStateLoss() dismissAllowingStateLoss()
return return

@ -99,7 +99,7 @@ class RemoteListFragment : MangaListFragment() {
override fun onQueryTextChange(newText: String?): Boolean = false override fun onQueryTextChange(newText: String?): Boolean = false
override fun onMenuItemActionExpand(item: MenuItem?): Boolean { override fun onMenuItemActionExpand(item: MenuItem): Boolean {
(activity as? AppBarOwner)?.appBar?.setExpanded(false, true) (activity as? AppBarOwner)?.appBar?.setExpanded(false, true)
return true return true
} }

@ -10,11 +10,14 @@ private const val USER_AGENT_SHIKIMORI = "Kotatsu"
class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor { class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder() val sourceRequest = chain.request()
val request = sourceRequest.newBuilder()
request.header(CommonHeaders.USER_AGENT, USER_AGENT_SHIKIMORI) request.header(CommonHeaders.USER_AGENT, USER_AGENT_SHIKIMORI)
if (!sourceRequest.url.pathSegments.contains("oauth")) {
storage.accessToken?.let { storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it") request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
} }
}
val response = chain.proceed(request.build()) val response = chain.proceed(request.build())
if (!response.isSuccessful && !response.isRedirect) { if (!response.isSuccessful && !response.isRedirect) {
throw IOException("${response.code} ${response.message}") throw IOException("${response.code} ${response.message}")

@ -40,13 +40,14 @@ class ShikimoriRepository(
suspend fun authorize(code: String?) { suspend fun authorize(code: String?) {
val body = FormBody.Builder() val body = FormBody.Builder()
body.add("grant_type", "authorization_code")
body.add("client_id", BuildConfig.SHIKIMORI_CLIENT_ID) body.add("client_id", BuildConfig.SHIKIMORI_CLIENT_ID)
body.add("client_secret", BuildConfig.SHIKIMORI_CLIENT_SECRET) body.add("client_secret", BuildConfig.SHIKIMORI_CLIENT_SECRET)
if (code != null) { if (code != null) {
body.add("grant_type", "authorization_code")
body.add("redirect_uri", REDIRECT_URI) body.add("redirect_uri", REDIRECT_URI)
body.add("code", code) body.add("code", code)
} else { } else {
body.add("grant_type", "refresh_token")
body.add("refresh_token", checkNotNull(storage.refreshToken)) body.add("refresh_token", checkNotNull(storage.refreshToken))
} }
val request = Request.Builder() val request = Request.Builder()

@ -26,6 +26,7 @@ import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ShikiMangaSelectionDe
import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ShikimoriSelectorAdapter import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ShikimoriSelectorAdapter
import org.koitharu.kotatsu.utils.ext.assistedViewModels import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.requireParcelable
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
@AndroidEntryPoint @AndroidEntryPoint
@ -47,7 +48,7 @@ class ScrobblingSelectorBottomSheet :
private val viewModel by assistedViewModels { private val viewModel by assistedViewModels {
viewModelFactory.create( viewModelFactory.create(
requireNotNull(requireArguments().getParcelable<ParcelableManga>(MangaIntent.KEY_MANGA)).manga, requireArguments().requireParcelable<ParcelableManga>(MangaIntent.KEY_MANGA).manga,
) )
} }
@ -84,7 +85,7 @@ class ScrobblingSelectorBottomSheet :
dismiss() dismiss()
} }
viewModel.searchQuery.observe(viewLifecycleOwner) { viewModel.searchQuery.observe(viewLifecycleOwner) {
binding.headerBar.toolbar.subtitle = it binding.headerBar.subtitle = it
} }
} }
@ -102,7 +103,7 @@ class ScrobblingSelectorBottomSheet :
viewModel.loadList(append = true) viewModel.loadList(append = true)
} }
override fun onMenuItemActionExpand(item: MenuItem?): Boolean { override fun onMenuItemActionExpand(item: MenuItem): Boolean {
setExpanded(isExpanded = true, isLocked = true) setExpanded(isExpanded = true, isLocked = true)
return true return true
} }

@ -17,6 +17,7 @@ import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
@AndroidEntryPoint @AndroidEntryPoint
class MangaListActivity : class MangaListActivity :
@ -29,7 +30,7 @@ class MangaListActivity :
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityContainerBinding.inflate(layoutInflater)) setContentView(ActivityContainerBinding.inflate(layoutInflater))
val tags = intent.getParcelableExtra<ParcelableMangaTags>(EXTRA_TAGS)?.tags val tags = intent.getParcelableExtraCompat<ParcelableMangaTags>(EXTRA_TAGS)?.tags
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
val source = intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: tags?.firstOrNull()?.source val source = intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: tags?.firstOrNull()?.source
if (source == null) { if (source == null) {

@ -1,110 +0,0 @@
package org.koitharu.kotatsu.settings
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.activity.ComponentActivity
import androidx.annotation.MainThread
import androidx.core.net.toUri
import com.google.android.material.R as materialR
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.security.MessageDigest
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.core.github.VersionId
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
@Deprecated("")
class AppUpdateChecker(private val activity: ComponentActivity) {
private val settings: AppSettings = TODO()
private val repo: AppUpdateRepository = TODO()
suspend fun checkIfNeeded(): Boolean? = if (
settings.isUpdateCheckingEnabled &&
settings.lastUpdateCheckTimestamp + PERIOD < System.currentTimeMillis()
) {
checkNow()
} else {
null
}
suspend fun checkNow() = runCatching {
val version = repo.fetchUpdate() ?: return@runCatching false
val newVersionId = VersionId(version.name)
val currentVersionId = VersionId(BuildConfig.VERSION_NAME)
val result = newVersionId > currentVersionId
if (result) {
withContext(Dispatchers.Main) {
showUpdateDialog(version)
}
}
settings.lastUpdateCheckTimestamp = System.currentTimeMillis()
result
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
@MainThread
private fun showUpdateDialog(version: AppVersion) {
val message = buildString {
append(activity.getString(R.string.new_version_s, version.name))
appendLine()
append(activity.getString(R.string.size_s, FileSize.BYTES.format(activity, version.apkSize)))
appendLine()
appendLine()
append(version.description)
}
MaterialAlertDialogBuilder(activity, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
.setTitle(R.string.app_update_available)
.setMessage(message)
.setIcon(R.drawable.ic_app_update)
.setPositiveButton(R.string.download) { _, _ ->
val intent = Intent(Intent.ACTION_VIEW, version.apkUrl.toUri())
activity.startActivity(Intent.createChooser(intent, activity.getString(R.string.open_in_browser)))
}
.setNegativeButton(R.string.close, null)
.setCancelable(false)
.create()
.show()
}
companion object {
private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE"
private val PERIOD = TimeUnit.HOURS.toMillis(6)
fun isUpdateSupported(context: Context): Boolean {
return BuildConfig.DEBUG || getCertificateSHA1Fingerprint(context) == CERT_SHA1
}
@Suppress("DEPRECATION")
@SuppressLint("PackageManagerGetSignatures")
private fun getCertificateSHA1Fingerprint(context: Context): String? = runCatching {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES)
val signatures = requireNotNull(packageInfo?.signatures)
val cert: ByteArray = signatures.first().toByteArray()
val input: InputStream = ByteArrayInputStream(cert)
val cf = CertificateFactory.getInstance("X509")
val c = cf.generateCertificate(input) as X509Certificate
val md: MessageDigest = MessageDigest.getInstance("SHA1")
val publicKey: ByteArray = md.digest(c.encoded)
return publicKey.byte2HexFormatted()
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrNull()
}
}

@ -13,6 +13,7 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.network.AndroidCookieJar import org.koitharu.kotatsu.core.network.AndroidCookieJar
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
@ -41,8 +42,13 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
@Inject @Inject
lateinit var cookieJar: AndroidCookieJar lateinit var cookieJar: AndroidCookieJar
@Inject
lateinit var shortcutsUpdater: ShortcutsUpdater
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_history) addPreferencesFromResource(R.xml.pref_history)
findPreference<Preference>(AppSettings.KEY_SHORTCUTS)?.isVisible =
shortcutsUpdater.isDynamicShortcutsAvailable()
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

@ -2,6 +2,8 @@ package org.koitharu.kotatsu.settings
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -53,7 +55,7 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(KEY_AUTH)?.run { findPreference<Preference>(KEY_AUTH)?.run {
if (isVisible) { if (isVisible) {
loadUsername(this) loadUsername(viewLifecycleOwner, this)
} }
} }
} }
@ -68,7 +70,7 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
} }
} }
private fun loadUsername(preference: Preference) = viewLifecycleScope.launch { private fun loadUsername(owner: LifecycleOwner, preference: Preference) = owner.lifecycleScope.launch {
runCatching { runCatching {
preference.summary = null preference.summary = null
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
@ -99,7 +101,8 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
viewLifecycleScope.launch { viewLifecycleScope.launch {
if (exceptionResolver.resolve(error)) { if (exceptionResolver.resolve(error)) {
val pref = findPreference<Preference>(KEY_AUTH) ?: return@launch val pref = findPreference<Preference>(KEY_AUTH) ?: return@launch
loadUsername(pref) val lifecycleOwner = awaitViewLifecycle()
loadUsername(lifecycleOwner, pref)
} }
} }
} }

@ -2,34 +2,41 @@ package org.koitharu.kotatsu.settings.about
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.fragment.app.viewModels
import androidx.preference.Preference import androidx.preference.Preference
import kotlinx.coroutines.launch import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.AppUpdateChecker
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
class AboutSettingsFragment : BasePreferenceFragment(R.string.about) { class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
private val viewModel by viewModels<AboutSettingsViewModel>()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_about) addPreferencesFromResource(R.xml.pref_about)
val isUpdateSupported = AppUpdateChecker.isUpdateSupported(requireContext())
findPreference<Preference>(AppSettings.KEY_APP_UPDATE_AUTO)?.run {
isVisible = isUpdateSupported
}
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run { findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
title = getString(R.string.app_version, BuildConfig.VERSION_NAME) title = getString(R.string.app_version, BuildConfig.VERSION_NAME)
isEnabled = isUpdateSupported isEnabled = viewModel.isUpdateSupported
}
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.isLoading.observe(viewLifecycleOwner) {
findPreference<Preference>(AppSettings.KEY_APP_UPDATE)?.isEnabled = !it
}
viewModel.onUpdateAvailable.observe(viewLifecycleOwner, ::onUpdateAvailable)
} }
override fun onPreferenceTreeClick(preference: Preference): Boolean { override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) { return when (preference.key) {
AppSettings.KEY_APP_VERSION -> { AppSettings.KEY_APP_VERSION -> {
checkForUpdates() viewModel.checkForUpdates()
true true
} }
AppSettings.KEY_APP_TRANSLATION -> { AppSettings.KEY_APP_TRANSLATION -> {
@ -40,24 +47,12 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
} }
} }
private fun checkForUpdates() { private fun onUpdateAvailable(version: AppVersion?) {
viewLifecycleScope.launch { if (version == null) {
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run { Snackbar.make(listView, R.string.no_update_available, Snackbar.LENGTH_SHORT).show()
setSummary(R.string.checking_for_updates) return
isSelectable = false
}
val result = AppUpdateChecker(activity ?: return@launch).checkNow()
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
setSummary(
when (result) {
true -> R.string.check_for_updates
false -> R.string.no_update_available
null -> R.string.update_check_failed
}
)
isSelectable = true
}
} }
AppUpdateDialog(context ?: return).show(version)
} }
private fun openLink(url: String, title: CharSequence?) { private fun openLink(url: String, title: CharSequence?) {
@ -68,7 +63,7 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
Intent.createChooser(intent, title) Intent.createChooser(intent, title)
} else { } else {
intent intent
} },
) )
} }
} }

@ -0,0 +1,24 @@
package org.koitharu.kotatsu.settings.about
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.utils.SingleLiveEvent
@HiltViewModel
class AboutSettingsViewModel @Inject constructor(
private val appUpdateRepository: AppUpdateRepository,
) : BaseViewModel() {
val isUpdateSupported = appUpdateRepository.isUpdateSupported()
val onUpdateAvailable = SingleLiveEvent<AppVersion?>()
fun checkForUpdates() {
launchLoadingJob {
val update = appUpdateRepository.fetchUpdate()
onUpdateAvailable.call(update)
}
}
}

@ -0,0 +1,39 @@
package org.koitharu.kotatsu.settings.about
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.google.android.material.R as materialR
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.utils.FileSize
class AppUpdateDialog(private val context: Context) {
fun show(version: AppVersion) {
val message = buildString {
append(context.getString(R.string.new_version_s, version.name))
appendLine()
append(context.getString(R.string.size_s, FileSize.BYTES.format(context, version.apkSize)))
appendLine()
appendLine()
append(version.description)
}
MaterialAlertDialogBuilder(
context,
materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered,
)
.setTitle(R.string.app_update_available)
.setMessage(message)
.setIcon(R.drawable.ic_app_update)
.setPositiveButton(R.string.download) { _, _ ->
val intent = Intent(Intent.ACTION_VIEW, version.apkUrl.toUri())
context.startActivity(Intent.createChooser(intent, context.getString(R.string.open_in_browser)))
}
.setNegativeButton(R.string.close, null)
.setCancelable(false)
.create()
.show()
}
}

@ -51,8 +51,9 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
viewModel.onError.observe(viewLifecycleOwner, this::onError) viewModel.onError.observe(viewLifecycleOwner, this::onError)
} }
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
builder.setCancelable(false) return super.onBuildDialog(builder)
.setCancelable(false)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
} }

@ -44,8 +44,9 @@ class RestoreDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
viewModel.onError.observe(viewLifecycleOwner, this::onError) viewModel.onError.observe(viewLifecycleOwner, this::onError)
} }
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
builder.setCancelable(false) return super.onBuildDialog(builder)
.setCancelable(false)
} }
private fun onError(e: Throwable) { private fun onError(e: Throwable) {

@ -43,8 +43,8 @@ class NewSourcesDialogFragment :
viewModel.sources.observe(viewLifecycleOwner) { adapter.items = it } viewModel.sources.observe(viewLifecycleOwner) { adapter.items = it }
} }
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
builder return super.onBuildDialog(builder)
.setPositiveButton(R.string.done, this) .setPositiveButton(R.string.done, this)
.setCancelable(true) .setCancelable(true)
.setTitle(R.string.remote_sources) .setTitle(R.string.remote_sources)

@ -39,8 +39,8 @@ class OnboardDialogFragment :
container: ViewGroup?, container: ViewGroup?,
) = DialogOnboardBinding.inflate(inflater, container, false) ) = DialogOnboardBinding.inflate(inflater, container, false)
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
builder super.onBuildDialog(builder)
.setPositiveButton(R.string.done, this) .setPositiveButton(R.string.done, this)
.setCancelable(true) .setCancelable(true)
if (isWelcome) { if (isWelcome) {
@ -50,6 +50,7 @@ class OnboardDialogFragment :
.setTitle(R.string.remote_sources) .setTitle(R.string.remote_sources)
.setNegativeButton(android.R.string.cancel, this) .setNegativeButton(android.R.string.cancel, this)
} }
return builder
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

@ -120,7 +120,7 @@ class SourcesSettingsFragment :
else -> false else -> false
} }
override fun onMenuItemActionExpand(item: MenuItem?): Boolean { override fun onMenuItemActionExpand(item: MenuItem): Boolean {
(activity as? AppBarOwner)?.appBar?.setExpanded(false, true) (activity as? AppBarOwner)?.appBar?.setExpanded(false, true)
return true return true
} }

@ -26,6 +26,7 @@ import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.databinding.FragmentToolsBinding import org.koitharu.kotatsu.databinding.FragmentToolsBinding
import org.koitharu.kotatsu.download.ui.DownloadsActivity import org.koitharu.kotatsu.download.ui.DownloadsActivity
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.about.AppUpdateDialog
import org.koitharu.kotatsu.settings.tools.model.StorageUsage import org.koitharu.kotatsu.settings.tools.model.StorageUsage
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.getThemeColor import org.koitharu.kotatsu.utils.ext.getThemeColor
@ -68,6 +69,10 @@ class ToolsFragment :
intent.data = url.toUri() intent.data = url.toUri()
startActivity(Intent.createChooser(intent, getString(R.string.open_in_browser))) startActivity(Intent.createChooser(intent, getString(R.string.open_in_browser)))
} }
R.id.card_update -> {
val version = viewModel.appUpdate.value ?: return
AppUpdateDialog(v.context).show(version)
}
} }
} }

@ -15,7 +15,7 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.onFirst import org.koitharu.kotatsu.utils.ext.onFirst
@HiltViewModel @HiltViewModel
@ -44,8 +44,8 @@ class SuggestionsViewModel @Inject constructor(
}.onFirst { }.onFirst {
loadingCounter.decrement() loadingCounter.decrement()
}.catch { }.catch {
it.toErrorState(canRetry = false) emit(listOf(it.toErrorState(canRetry = false)))
}.asLiveDataDistinct( }.asFlowLiveData(
viewModelScope.coroutineContext + Dispatchers.Default, viewModelScope.coroutineContext + Dispatchers.Default,
listOf(LoadingState), listOf(LoadingState),
) )

@ -5,9 +5,26 @@ import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
fun TrackLogWithManga.toTrackingLogItem() = TrackingLogItem( fun TrackLogWithManga.toTrackingLogItem(counters: MutableMap<Long, Int>): TrackingLogItem {
val chaptersList = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() }
return TrackingLogItem(
id = trackLog.id, id = trackLog.id,
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() }, chapters = chaptersList,
manga = manga.toManga(tags.toMangaTags()), manga = manga.toManga(tags.toMangaTags()),
createdAt = Date(trackLog.createdAt) createdAt = Date(trackLog.createdAt),
isNew = counters.decrement(trackLog.mangaId, chaptersList.size),
) )
}
private fun MutableMap<Long, Int>.decrement(key: Long, count: Int): Boolean {
val counter = get(key)
if (counter == null || counter <= 0) {
return false
}
if (counter < count) {
remove(key)
} else {
put(key, counter - count)
}
return true
}

@ -18,6 +18,10 @@ abstract class TracksDao {
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId") @Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun findNewChapters(mangaId: Long): Int? abstract suspend fun findNewChapters(mangaId: Long): Int?
@MapInfo(keyColumn = "manga_id", valueColumn = "chapters_new")
@Query("SELECT manga_id, chapters_new FROM tracks")
abstract fun observeNewChaptersMap(): Flow<Map<Long, Int>>
@Query("SELECT chapters_new FROM tracks") @Query("SELECT chapters_new FROM tracks")
abstract fun observeNewChapters(): Flow<List<Int>> abstract fun observeNewChapters(): Flow<List<Int>>

@ -5,6 +5,8 @@ import androidx.room.withTransaction
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
@ -73,9 +75,15 @@ class TrackingRepository @Inject constructor(
db.tracksDao.delete(mangaId) db.tracksDao.delete(mangaId)
} }
suspend fun getTrackingLog(offset: Int, limit: Int): List<TrackingLogItem> { fun observeTrackingLog(limit: Flow<Int>): Flow<List<TrackingLogItem>> {
return db.trackLogsDao.findAll(offset, limit).map { x -> return limit.flatMapLatest { limitValue ->
x.toTrackingLogItem() combine(
db.tracksDao.observeNewChaptersMap(),
db.trackLogsDao.observeAll(limitValue),
) { counters, entities ->
val countersMap = counters.toMutableMap()
entities.map { x -> x.toTrackingLogItem(countersMap) }
}
} }
} }

@ -7,5 +7,6 @@ data class TrackingLogItem(
val id: Long, val id: Long,
val manga: Manga, val manga: Manga,
val chapters: List<String>, val chapters: List<String>,
val createdAt: Date val createdAt: Date,
val isNew: Boolean,
) )

@ -137,7 +137,7 @@ class FeedFragment :
} }
override fun onScrolledToEnd() { override fun onScrolledToEnd() {
viewModel.loadList(append = true) viewModel.requestMoreItems()
} }
override fun onItemClick(item: Manga, view: View) { override fun onItemClick(item: Manga, view: View) {

@ -4,44 +4,39 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.DateTimeAgo import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel 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.LoadingState
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import org.koitharu.kotatsu.tracker.ui.model.toFeedItem import org.koitharu.kotatsu.tracker.ui.model.toFeedItem
import org.koitharu.kotatsu.utils.SingleLiveEvent 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.daysDiff import org.koitharu.kotatsu.utils.ext.daysDiff
private const val PAGE_SIZE = 20
@HiltViewModel @HiltViewModel
class FeedViewModel @Inject constructor( class FeedViewModel @Inject constructor(
private val repository: TrackingRepository, private val repository: TrackingRepository,
) : BaseViewModel() { ) : BaseViewModel() {
private val logList = MutableStateFlow<List<TrackingLogItem>?>(null) private val limit = MutableStateFlow(PAGE_SIZE)
private val hasNextPage = MutableStateFlow(false) private val isReady = AtomicBoolean(false)
private var loadingJob: Job? = null
val onFeedCleared = SingleLiveEvent<Unit>() val onFeedCleared = SingleLiveEvent<Unit>()
val content = combine( val content = repository.observeTrackingLog(limit)
logList.filterNotNull(), .map { list ->
hasNextPage,
) { list, isHasNextPage ->
buildList(list.size + 2) {
if (list.isEmpty()) { if (list.isEmpty()) {
add( listOf(
EmptyState( EmptyState(
icon = R.drawable.ic_empty_feed, icon = R.drawable.ic_empty_feed,
textPrimary = R.string.text_empty_holder_primary, textPrimary = R.string.text_empty_holder_primary,
@ -50,48 +45,26 @@ class FeedViewModel @Inject constructor(
), ),
) )
} else { } else {
list.mapListTo(this) isReady.set(true)
if (isHasNextPage) { list.mapList()
add(LoadingFooter)
}
}
}
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
init {
loadList(append = false)
}
fun loadList(append: Boolean) {
if (loadingJob?.isActive == true) {
return
}
if (append && !hasNextPage.value) {
return
}
loadingJob = launchLoadingJob(Dispatchers.Default) {
val offset = if (append) logList.value?.size ?: 0 else 0
val list = repository.getTrackingLog(offset, 20)
if (!append) {
logList.value = list
} else if (list.isNotEmpty()) {
logList.value = logList.value?.plus(list) ?: list
}
hasNextPage.value = list.isNotEmpty()
}
} }
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
fun clearFeed() { fun clearFeed() {
val lastJob = loadingJob launchLoadingJob(Dispatchers.Default) {
loadingJob = launchLoadingJob(Dispatchers.Default) {
lastJob?.cancelAndJoin()
repository.clearLogs() repository.clearLogs()
logList.value = emptyList()
onFeedCleared.postCall(Unit) onFeedCleared.postCall(Unit)
} }
} }
private fun List<TrackingLogItem>.mapListTo(destination: MutableList<ListModel>) { fun requestMoreItems() {
if (isReady.compareAndSet(true, false)) {
limit.value += PAGE_SIZE
}
}
private fun List<TrackingLogItem>.mapList(): List<ListModel> {
val destination = ArrayList<ListModel>((size * 1.4).toInt())
var prevDate: DateTimeAgo? = null var prevDate: DateTimeAgo? = null
for (item in this) { for (item in this) {
val date = timeAgo(item.createdAt) val date = timeAgo(item.createdAt)
@ -101,6 +74,7 @@ class FeedViewModel @Inject constructor(
prevDate = date prevDate = date
destination += item.toFeedItem() destination += item.toFeedItem()
} }
return destination
} }
private fun timeAgo(date: Date): DateTimeAgo { private fun timeAgo(date: Date): DateTimeAgo {

@ -11,22 +11,23 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.ui.model.FeedItem import org.koitharu.kotatsu.tracker.ui.model.FeedItem
import org.koitharu.kotatsu.utils.ext.disposeImageRequest import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.isLowRamDevice import org.koitharu.kotatsu.utils.ext.isBold
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
fun feedItemAD( fun feedItemAD(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga> clickListener: OnListItemClickListener<Manga>,
) = adapterDelegateViewBinding<FeedItem, ListModel, ItemFeedBinding>( ) = adapterDelegateViewBinding<FeedItem, ListModel, ItemFeedBinding>(
{ inflater, parent -> ItemFeedBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemFeedBinding.inflate(inflater, parent, false) },
) { ) {
itemView.setOnClickListener { itemView.setOnClickListener {
clickListener.onItemClick(item.manga, it) clickListener.onItemClick(item.manga, it)
} }
bind { bind {
binding.textViewTitle.isBold = item.isNew
binding.textViewSummary.isBold = item.isNew
binding.imageViewCover.newImageRequest(item.imageUrl)?.run { binding.imageViewCover.newImageRequest(item.imageUrl)?.run {
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)

@ -9,4 +9,5 @@ data class FeedItem(
val title: String, val title: String,
val manga: Manga, val manga: Manga,
val count: Int, val count: Int,
val isNew: Boolean,
) : ListModel ) : ListModel

@ -8,4 +8,5 @@ fun TrackingLogItem.toFeedItem() = FeedItem(
title = manga.title, title = manga.title,
count = chapters.size, count = chapters.size,
manga = manga, manga = manga,
isNew = isNew,
) )

@ -7,7 +7,7 @@ import kotlin.math.roundToInt
class GridTouchHelper( class GridTouchHelper(
context: Context, context: Context,
private val listener: OnGridTouchListener private val listener: OnGridTouchListener,
) : GestureDetector.SimpleOnGestureListener() { ) : GestureDetector.SimpleOnGestureListener() {
private val detector = GestureDetector(context, this) private val detector = GestureDetector(context, this)
@ -16,7 +16,7 @@ class GridTouchHelper(
private var isDispatching = false private var isDispatching = false
init { init {
detector.setIsLongpressEnabled(false) detector.setIsLongpressEnabled(true)
detector.setOnDoubleTapListener(this) detector.setOnDoubleTapListener(this)
} }
@ -46,7 +46,7 @@ class GridTouchHelper(
} }
2 -> AREA_RIGHT 2 -> AREA_RIGHT
else -> return false else -> return false
} },
) )
return true return true
} }

@ -0,0 +1,37 @@
package org.koitharu.kotatsu.utils
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
class IdlingDetector(
private val timeoutMs: Long,
private val callback: Callback,
) : DefaultLifecycleObserver {
private val handler = Handler(Looper.getMainLooper())
private val idleRunnable = Runnable {
callback.onIdle()
}
fun bindToLifecycle(owner: LifecycleOwner) {
owner.lifecycle.addObserver(this)
}
fun onUserInteraction() {
handler.removeCallbacks(idleRunnable)
handler.postDelayed(idleRunnable, timeoutMs)
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
owner.lifecycle.removeObserver(this)
handler.removeCallbacks(idleRunnable)
}
fun interface Callback {
fun onIdle()
}
}

@ -0,0 +1,46 @@
package org.koitharu.kotatsu.utils.ext
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import java.io.Serializable
@Suppress("DEPRECATION")
inline fun <reified T : Parcelable> Bundle.getParcelableCompat(key: String): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelable(key, T::class.java)
} else {
getParcelable(key) as? T
}
}
@Suppress("DEPRECATION")
inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableExtra(key, T::class.java)
} else {
getParcelableExtra(key) as? T
}
}
@Suppress("DEPRECATION")
inline fun <reified T : Serializable> Bundle.getSerializableCompat(key: String): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getSerializable(key, T::class.java)
} else {
getSerializable(key) as? T
}
}
inline fun <reified T : Serializable> Bundle.requireSerializable(key: String): T {
return checkNotNull(getSerializableCompat(key)) {
"Serializable of type \"${T::class.java.name}\" not found at \"$key\""
}
}
inline fun <reified T : Parcelable> Bundle.requireParcelable(key: String): T {
return checkNotNull(getParcelableCompat(key)) {
"Parcelable of type \"${T::class.java.name}\" not found at \"$key\""
}
}

@ -10,6 +10,7 @@ import coil.request.ImageResult
import coil.request.SuccessResult import coil.request.SuccessResult
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.progressindicator.BaseProgressIndicator import com.google.android.material.progressindicator.BaseProgressIndicator
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener
@ -47,7 +48,18 @@ fun ImageResult.toBitmapOrNull() = when (this) {
} }
fun ImageRequest.Builder.referer(referer: String): ImageRequest.Builder { fun ImageRequest.Builder.referer(referer: String): ImageRequest.Builder {
return setHeader(CommonHeaders.REFERER, referer) if (referer.isEmpty()) {
return this
}
try {
setHeader(CommonHeaders.REFERER, referer)
} catch (e: IllegalArgumentException) {
val baseUrl = referer.baseUrl()
if (baseUrl != null) {
setHeader(CommonHeaders.REFERER, baseUrl)
}
}
return this
} }
fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRequest.Builder { fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRequest.Builder {
@ -63,3 +75,11 @@ fun ImageRequest.Builder.crossfade(context: Context?): ImageRequest.Builder {
val duration = context.resources.getInteger(R.integer.config_defaultAnimTime) * context.animatorDurationScale val duration = context.resources.getInteger(R.integer.config_defaultAnimTime) * context.animatorDurationScale
return crossfade(duration.toInt()) return crossfade(duration.toInt())
} }
private fun String.baseUrl(): String? {
return (this.toHttpUrlOrNull()?.newBuilder("/") ?: return null)
.username("")
.password("")
.build()
.toString()
}

@ -1,14 +1,17 @@
package org.koitharu.kotatsu.utils.ext package org.koitharu.kotatsu.utils.ext
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.lifecycle.coroutineScope import androidx.lifecycle.coroutineScope
import java.io.Serializable import java.io.Serializable
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
inline fun <T : Fragment> T.withArgs(size: Int, block: Bundle.() -> Unit): T { inline fun <T : Fragment> T.withArgs(size: Int, block: Bundle.() -> Unit): T {
val b = Bundle(size) val b = Bundle(size)
@ -20,18 +23,10 @@ inline fun <T : Fragment> T.withArgs(size: Int, block: Bundle.() -> Unit): T {
val Fragment.viewLifecycleScope val Fragment.viewLifecycleScope
inline get() = viewLifecycleOwner.lifecycle.coroutineScope inline get() = viewLifecycleOwner.lifecycle.coroutineScope
fun <T : Parcelable> Fragment.parcelableArgument(name: String): Lazy<T> {
return lazy(LazyThreadSafetyMode.NONE) {
requireNotNull(arguments?.getParcelable(name)) {
"No argument $name passed into ${javaClass.simpleName}"
}
}
}
fun <T : Serializable> Fragment.serializableArgument(name: String): Lazy<T> { fun <T : Serializable> Fragment.serializableArgument(name: String): Lazy<T> {
return lazy(LazyThreadSafetyMode.NONE) { return lazy(LazyThreadSafetyMode.NONE) {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
requireNotNull(arguments?.getSerializable(name)) { requireNotNull(arguments?.getSerializableCompat(name)) {
"No argument $name passed into ${javaClass.simpleName}" "No argument $name passed into ${javaClass.simpleName}"
} as T } as T
} }
@ -50,3 +45,19 @@ fun DialogFragment.showAllowStateLoss(manager: FragmentManager, tag: String?) {
fun Fragment.addMenuProvider(provider: MenuProvider) { fun Fragment.addMenuProvider(provider: MenuProvider) {
requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.STARTED) requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.STARTED)
} }
suspend fun Fragment.awaitViewLifecycle(): LifecycleOwner = suspendCancellableCoroutine { cont ->
val liveData = viewLifecycleOwnerLiveData
val observer = object : Observer<LifecycleOwner> {
override fun onChanged(result: LifecycleOwner?) {
if (result != null) {
liveData.removeObserver(this)
cont.resume(result)
}
}
}
liveData.observeForever(observer)
cont.invokeOnCancellation {
liveData.removeObserver(observer)
}
}

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.utils.ext package org.koitharu.kotatsu.utils.ext
import android.graphics.Typeface
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.view.View import android.view.View
import android.widget.TextView import android.widget.TextView
@ -48,3 +49,15 @@ fun TextView.setTextAndVisible(@StringRes textResId: Int) {
fun TextView.setTextColorAttr(@AttrRes attrResId: Int) { fun TextView.setTextColorAttr(@AttrRes attrResId: Int) {
setTextColor(context.getThemeColorStateList(attrResId)) setTextColor(context.getThemeColorStateList(attrResId))
} }
var TextView.isBold: Boolean
get() = typeface.isBold
set(value) {
var style = typeface.style
style = if (value) {
style or Typeface.BOLD
} else {
style and Typeface.BOLD.inv()
}
setTypeface(typeface, style)
}

@ -2,11 +2,14 @@ package org.koitharu.kotatsu.utils.ext
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.widget.TextView
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.FloatRange import androidx.annotation.FloatRange
import androidx.annotation.StyleRes
import androidx.core.content.res.use import androidx.core.content.res.use
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import androidx.core.widget.TextViewCompat
fun Context.getThemeDrawable( fun Context.getThemeDrawable(
@AttrRes resId: Int, @AttrRes resId: Int,
@ -43,3 +46,9 @@ fun Context.getThemeColorStateList(
) = obtainStyledAttributes(intArrayOf(resId)).use { ) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getColorStateList(0) it.getColorStateList(0)
} }
fun TextView.setThemeTextAppearance(@AttrRes resId: Int, @StyleRes fallback: Int) {
context.obtainStyledAttributes(intArrayOf(resId)).use {
TextViewCompat.setTextAppearance(this, it.getResourceId(0, fallback))
}
}

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.utils.ext
import android.app.Activity import android.app.Activity
import android.graphics.Rect import android.graphics.Rect
import android.view.View import android.view.View
import android.view.View.MeasureSpec
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewParent import android.view.ViewParent
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
@ -167,3 +168,19 @@ val View.parents: Sequence<ViewParent>
p = p.parent p = p.parent
} }
} }
@Suppress("unused")
fun View.measureDimension(desiredSize: Int, measureSpec: Int): Int {
var result: Int
val specMode = MeasureSpec.getMode(measureSpec)
val specSize = MeasureSpec.getSize(measureSpec)
if (specMode == MeasureSpec.EXACTLY) {
result = specSize
} else {
result = desiredSize
if (specMode == MeasureSpec.AT_MOST) {
result = result.coerceAtMost(specSize)
}
}
return result
}

@ -181,11 +181,11 @@
android:id="@+id/textView_description" android:id="@+id/textView_description"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="16dp" android:layout_marginStart="@dimen/margin_normal"
android:layout_marginTop="8dp" android:layout_marginTop="@dimen/margin_small"
android:layout_marginEnd="16dp" android:layout_marginEnd="@dimen/margin_normal"
android:layout_marginBottom="16dp"
android:lineSpacingMultiplier="1.2" android:lineSpacingMultiplier="1.2"
android:paddingBottom="@dimen/margin_normal"
android:textAppearance="?attr/textAppearanceBodyMedium" android:textAppearance="?attr/textAppearanceBodyMedium"
android:textIsSelectable="true" android:textIsSelectable="true"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"

@ -63,56 +63,66 @@
app:layout_constraintWidth_percent="0.5" app:layout_constraintWidth_percent="0.5"
tools:layout="@layout/fragment_details" /> tools:layout="@layout/fragment_details" />
<ImageView <RelativeLayout
android:id="@+id/button_dropdown" android:id="@+id/group_header"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:padding="@dimen/margin_small"
android:src="@drawable/ic_expand_more"
app:layout_constraintBottom_toTopOf="@id/divider"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/appbar" /> app:layout_constraintStart_toStartOf="@id/container_chapters"
app:layout_constraintTop_toBottomOf="@id/appbar">
<TextView <TextView
android:id="@+id/textView_title" android:id="@+id/textView_title"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignWithParentIfMissing="true"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:layout_marginTop="4dp"
android:layout_toStartOf="@id/button_dropdown"
android:paddingHorizontal="@dimen/margin_normal" android:paddingHorizontal="@dimen/margin_normal"
android:singleLine="true" android:singleLine="true"
android:textAppearance="?textAppearanceTitleMedium" android:textAppearance="?textAppearanceTitleMedium"
app:layout_constraintBottom_toTopOf="@id/textView_subtitle"
app:layout_constraintEnd_toStartOf="@id/button_dropdown"
app:layout_constraintStart_toStartOf="@id/container_chapters"
app:layout_constraintTop_toBottomOf="@id/appbar"
app:layout_constraintVertical_chainStyle="packed"
tools:text="@string/chapter_d_of_d" /> tools:text="@string/chapter_d_of_d" />
<TextView <TextView
android:id="@+id/textView_subtitle" android:id="@+id/textView_subtitle"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignWithParentIfMissing="true"
android:layout_below="@id/textView_title"
android:layout_alignParentStart="true"
android:layout_toStartOf="@id/button_dropdown"
android:paddingHorizontal="@dimen/margin_normal" android:paddingHorizontal="@dimen/margin_normal"
android:singleLine="true" android:singleLine="true"
android:textAppearance="?textAppearanceTitleSmall" android:textAppearance="?textAppearanceTitleSmall"
android:textColor="?android:textColorSecondary" android:textColor="?android:textColorSecondary"
android:visibility="gone" android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/divider"
app:layout_constraintEnd_toStartOf="@id/button_dropdown"
app:layout_constraintStart_toStartOf="@id/container_chapters"
app:layout_constraintTop_toBottomOf="@id/textView_title"
tools:text="English" tools:text="English"
tools:visibility="visible" /> tools:visibility="visible" />
<ImageView
android:id="@+id/button_dropdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:layout_marginBottom="1dp"
android:background="?selectableItemBackgroundBorderless"
android:padding="@dimen/margin_small"
android:src="@drawable/ic_expand_more" />
<View <View
android:id="@+id/divider" android:id="@+id/divider"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="1dp" android:layout_height="1dp"
android:layout_marginTop="48dp" android:layout_below="@id/textView_subtitle"
android:background="?colorSecondaryContainer" android:layout_alignParentStart="true"
app:layout_constraintEnd_toEndOf="parent" android:layout_alignParentEnd="true"
app:layout_constraintStart_toEndOf="@id/container_details" android:layout_marginTop="4dp"
app:layout_constraintTop_toBottomOf="@id/appbar" /> android:background="?colorSecondaryContainer" />
</RelativeLayout>
<androidx.fragment.app.FragmentContainerView <androidx.fragment.app.FragmentContainerView
android:id="@+id/container_chapters" android:id="@+id/container_chapters"
@ -122,7 +132,7 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/container_details" app:layout_constraintStart_toEndOf="@id/container_details"
app:layout_constraintTop_toBottomOf="@id/divider" app:layout_constraintTop_toBottomOf="@id/group_header"
tools:layout="@layout/fragment_chapters" /> tools:layout="@layout/fragment_chapters" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

@ -42,9 +42,11 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:visibility="gone"
app:behavior_hideable="false" app:behavior_hideable="false"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.BottomSheet"> app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.BottomSheet"
tools:visibility="visible">
<org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar <org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar
android:id="@+id/header_chapters" android:id="@+id/header_chapters"

@ -1,13 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout <FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView <com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
android:id="@+id/ssiv" android:id="@+id/ssiv"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent"
app:restoreStrategy="deferred" />
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar" android:id="@+id/toolbar"

@ -28,9 +28,9 @@
<org.koitharu.kotatsu.reader.ui.ReaderInfoBarView <org.koitharu.kotatsu.reader.ui.ReaderInfoBarView
android:id="@+id/infoBar" android:id="@+id/infoBar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="16dp" android:layout_height="wrap_content"
android:layout_gravity="top" android:layout_gravity="top"
android:paddingHorizontal="4dp" android:minHeight="12sp"
android:visibility="gone" android:visibility="gone"
tools:visibility="visible" /> tools:visibility="visible" />

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="@dimen/margin_normal"
android:paddingTop="@dimen/margin_normal">
<TextView
android:id="@+id/textView_message"
style="@style/MaterialAlertDialog.Material3.Body.Text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:linksClickable="true"
tools:text="@tools:sample/lorem[20]" />
</LinearLayout>
</ScrollView>

@ -193,11 +193,11 @@
android:id="@+id/textView_description" android:id="@+id/textView_description"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="16dp" android:layout_marginStart="@dimen/margin_normal"
android:layout_marginTop="8dp" android:layout_marginTop="@dimen/margin_small"
android:layout_marginEnd="16dp" android:layout_marginEnd="@dimen/margin_normal"
android:layout_marginBottom="16dp"
android:lineSpacingMultiplier="1.2" android:lineSpacingMultiplier="1.2"
android:paddingBottom="@dimen/margin_normal"
android:textAppearance="?attr/textAppearanceBodyMedium" android:textAppearance="?attr/textAppearanceBodyMedium"
android:textIsSelectable="true" android:textIsSelectable="true"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout <FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
@ -9,7 +10,8 @@
<com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView <com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
android:id="@+id/ssiv" android:id="@+id/ssiv"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent"
app:restoreStrategy="deferred" />
<TextView <TextView
android:id="@+id/textView_number" android:id="@+id/textView_number"

@ -2,7 +2,6 @@
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/card_update" android:id="@+id/card_update"
style="@style/Widget.Material3.CardView.Filled" style="@style/Widget.Material3.CardView.Filled"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -53,7 +52,7 @@
style="@style/Widget.Material3.Button" style="@style/Widget.Material3.Button"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small" android:layout_marginTop="12dp"
android:text="@string/download" android:text="@string/download"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/textSecondary" /> app:layout_constraintTop_toBottomOf="@id/textSecondary" />

@ -62,6 +62,7 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:background="?selectableItemBackgroundBorderless"
android:drawablePadding="4dp" android:drawablePadding="4dp"
android:gravity="center" android:gravity="center"
android:padding="4dp" android:padding="4dp"

@ -6,10 +6,13 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
tools:parentTag="com.google.android.material.appbar.AppBarLayout"> tools:parentTag="com.google.android.material.appbar.AppBarLayout">
<FrameLayout <com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:id="@+id/frame" android:id="@+id/dragHandle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"> android:layout_height="@dimen/bottom_sheet_handle_size_min"
android:minHeight="0dp"
android:paddingTop="12dp"
android:paddingBottom="0dp" />
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar" android:id="@+id/toolbar"
@ -19,14 +22,4 @@
tools:navigationIcon="?actionModeCloseDrawable" tools:navigationIcon="?actionModeCloseDrawable"
tools:title="@string/options" /> tools:title="@string/options" />
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:id="@+id/dragHandle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:paddingTop="12dp"
android:paddingBottom="0dp" />
</FrameLayout>
</merge> </merge>

@ -9,7 +9,7 @@
android:layout_marginHorizontal="16dp" android:layout_marginHorizontal="16dp"
android:layout_marginVertical="8dp" android:layout_marginVertical="8dp"
android:paddingBottom="8dp" android:paddingBottom="8dp"
app:cardBackgroundColor="?colorOnPrimary" app:cardBackgroundColor="?colorPrimaryContainer"
app:cardCornerRadius="24dp"> app:cardCornerRadius="24dp">
<LinearLayout <LinearLayout

@ -103,7 +103,7 @@
android:text="@string/reader_mode_hint" android:text="@string/reader_mode_hint"
android:textAppearance="?attr/textAppearanceBodySmall" /> android:textAppearance="?attr/textAppearanceBodySmall" />
<LinearLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal" android:layout_marginTop="@dimen/margin_normal"
@ -115,23 +115,38 @@
android:paddingEnd="?android:listPreferredItemPaddingEnd"> android:paddingEnd="?android:listPreferredItemPaddingEnd">
<TextView <TextView
android:layout_width="wrap_content" android:id="@+id/textView_timer"
android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:drawablePadding="?android:listPreferredItemPaddingStart" android:drawablePadding="?android:listPreferredItemPaddingStart"
android:ellipsize="end"
android:singleLine="true"
android:text="@string/automatic_scroll" android:text="@string/automatic_scroll"
android:textAppearance="?attr/textAppearanceButton" android:textAppearance="?attr/textAppearanceButton"
app:drawableStartCompat="@drawable/ic_timer" /> app:drawableStartCompat="@drawable/ic_timer"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/slider_timer"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_default="wrap" />
<com.google.android.material.slider.Slider <com.google.android.material.slider.Slider
android:id="@+id/slider_timer" android:id="@+id/slider_timer"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_normal" android:layout_marginStart="@dimen/margin_normal"
android:contentDescription="@string/automatic_scroll"
android:labelFor="@id/textView_timer"
android:valueFrom="0" android:valueFrom="0"
android:valueTo="20" android:valueTo="20"
app:labelBehavior="floating" /> app:labelBehavior="floating"
app:layout_constraintBottom_toBottomOf="parent"
</LinearLayout> app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/textView_timer"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_min="120dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
<org.koitharu.kotatsu.base.ui.widgets.ListItemTextView <org.koitharu.kotatsu.base.ui.widgets.ListItemTextView
android:id="@+id/button_color_filter" android:id="@+id/button_color_filter"

@ -288,4 +288,42 @@
<string name="reverse">Balik</string> <string name="reverse">Balik</string>
<string name="queued">Diantrikan</string> <string name="queued">Diantrikan</string>
<string name="auth_complete">Berhasil Diotorisasi</string> <string name="auth_complete">Berhasil Diotorisasi</string>
<string name="text_local_holder_secondary">Simpan dari sumber daring atau berkas impor.</string>
<string name="text_shelf_holder_primary">Manga Anda akan ditampilkan di sini</string>
<string name="text_shelf_holder_secondary">Cari apa untuk dibaca di bagian «Jelajah»</string>
<string name="email_enter_hint">Masukkan surel Anda untuk melanjutkan</string>
<string name="status_re_reading">Dibaca ulang</string>
<string name="explore">Jelajah</string>
<string name="status_planned">Direncanakan</string>
<string name="status_completed">Selesai</string>
<string name="canceled">Dibatalkan</string>
<string name="sync_title">Sinkronisasi data Anda</string>
<string name="enter_email_text">Masukkan surel Anda untuk melanjutkan</string>
<string name="tracking">Pelacakan</string>
<string name="logout">Keluar</string>
<string name="sync">Sinkronisasi</string>
<string name="send">Terkirim</string>
<string name="status_reading">Dibaca</string>
<string name="status_on_hold">Ditunda</string>
<string name="invalid_domain_message">Domain tidak valid</string>
<string name="no_bookmarks_yet">Belum ada markah</string>
<string name="no_bookmarks_summary">Anda bisa membuat markah ketika membaca manga</string>
<string name="bookmarks_removed">Markah dihapus</string>
<string name="no_manga_sources">Tidak ada sumber manga</string>
<string name="random">Acak</string>
<string name="empty">Kosong</string>
<string name="changelog">Daftar Perubahan</string>
<string name="removed_from_s">Dihapus dari \"%s\"</string>
<string name="reader_info_bar">Tampilkan bilah informasi di pembaca</string>
<string name="importing_manga">Mengimpor manga</string>
<string name="last_2_hours">Dua jam terakhir</string>
<string name="downloading_manga">Mengunduh manga</string>
<string name="show_all">Tampilkan semua</string>
<string name="history_cleared">Riwayat dihapus</string>
<string name="clear_all_history">Hapus semua riwayat</string>
<string name="options">Pilihan</string>
<string name="import_completed">Impor selesai</string>
<string name="removed_from_favourites">Dihapus dari favorit</string>
<string name="import_will_start_soon">Impor akan segera dimulai</string>
<string name="not_found_404">Konten tidak ditemukan atau dihapus</string>
</resources> </resources>

@ -372,4 +372,6 @@
<string name="reader_info_pattern">Ch.%1$d/%2$d Pg.%3$d/%4$d</string> <string name="reader_info_pattern">Ch.%1$d/%2$d Pg.%3$d/%4$d</string>
<string name="reader_info_bar">リーダーで情報バーを表示する</string> <string name="reader_info_bar">リーダーで情報バーを表示する</string>
<string name="comics_archive">コミックアーカイブ</string> <string name="comics_archive">コミックアーカイブ</string>
<string name="downloading_manga">漫画をダウンロードする</string>
<string name="feed">フィード</string>
</resources> </resources>

@ -327,7 +327,7 @@
<string name="bookmarks_removed">Закладки удалены</string> <string name="bookmarks_removed">Закладки удалены</string>
<string name="no_manga_sources">Нет источников манги</string> <string name="no_manga_sources">Нет источников манги</string>
<string name="no_manga_sources_text">Включите источники манги для чтения онлайн</string> <string name="no_manga_sources_text">Включите источники манги для чтения онлайн</string>
<string name="random">Рандом</string> <string name="random">Случайная</string>
<string name="reorder">Упорядочить</string> <string name="reorder">Упорядочить</string>
<string name="empty">Пусто</string> <string name="empty">Пусто</string>
<string name="explore">Обзор</string> <string name="explore">Обзор</string>
@ -338,7 +338,7 @@
<string name="enter_email_text">Введите электронную почту, чтобы продолжить</string> <string name="enter_email_text">Введите электронную почту, чтобы продолжить</string>
<string name="options">Опции</string> <string name="options">Опции</string>
<string name="not_found_404">Контент не найден или был удален</string> <string name="not_found_404">Контент не найден или был удален</string>
<string name="off_short">Отключено</string> <string name="off_short">Выкл.</string>
<string name="seconds_pattern">%s с</string> <string name="seconds_pattern">%s с</string>
<string name="reader_info_pattern">Гл. %1$d/%2$d Стр. %3$d/%4$d</string> <string name="reader_info_pattern">Гл. %1$d/%2$d Стр. %3$d/%4$d</string>
<string name="reader_info_bar">Показывать информационную панель в режиме чтения</string> <string name="reader_info_bar">Показывать информационную панель в режиме чтения</string>
@ -372,4 +372,6 @@
<string name="importing_manga">Импорт манги</string> <string name="importing_manga">Импорт манги</string>
<string name="import_completed">Импорт завершён</string> <string name="import_completed">Импорт завершён</string>
<string name="import_will_start_soon">Импорт скоро начнётся</string> <string name="import_will_start_soon">Импорт скоро начнётся</string>
<string name="feed">Лента</string>
<string name="downloading_manga">Загрузка манги</string>
</resources> </resources>

@ -320,4 +320,57 @@
<string name="show_all">Показати всі</string> <string name="show_all">Показати всі</string>
<string name="select_range">Виберіть діапазон</string> <string name="select_range">Виберіть діапазон</string>
<string name="not_found_404">Вміст не знайдено або видалено</string> <string name="not_found_404">Вміст не знайдено або видалено</string>
<string name="text_shelf_holder_primary">Ваша манга буде відображатися тут</string>
<string name="text_shelf_holder_secondary">Знайдіть, що почитати у розділі «Огляд»</string>
<string name="back">Назад</string>
<string name="email_enter_hint">Введіть свою електронну пошту, щоб продовжити</string>
<string name="history_cleared">Історія очищена</string>
<string name="manage">Керувати</string>
<string name="no_bookmarks_yet">Закладок ще немає</string>
<string name="no_bookmarks_summary">Ви можете створювати закладки під час читання манґи</string>
<string name="bookmarks_removed">Закладки видалено</string>
<string name="no_manga_sources">Немає джерел манґи</string>
<string name="no_manga_sources_text">Увімкніть джерела манґи, щоб читати онлайн</string>
<string name="random">Випадкова</string>
<string name="categories_delete_confirm">Ви впевнені, що бажаєте видалити вибрані улюблені категорії\?
\nУсю манґу в них буде втрачено, і це неможливо скасувати.</string>
<string name="reorder">Впорядкувати</string>
<string name="empty">Порожньо</string>
<string name="changelog">Журнал змін</string>
<string name="explore">Огляд</string>
<string name="saved_manga">Збережена манґа</string>
<string name="pages_cache">Кеш сторінок</string>
<string name="storage_usage">Використання сховища</string>
<string name="available">Доступні</string>
<string name="removed_from_s">Видалено з \"%s\"</string>
<string name="options">Параметри</string>
<string name="downloading_manga">Завантаження манґи</string>
<string name="incognito_mode">Режим інкогніто</string>
<string name="app_update_available_s">Доступне оновлення програми: %s</string>
<string name="no_chapters">Немає розділів</string>
<string name="automatic_scroll">Автоматична прокрутка</string>
<string name="off_short">Викл.</string>
<string name="reader_info_bar">Показувати інформаційну панель у режимі читання</string>
<string name="comics_archive">Архів коміксів</string>
<string name="folder_with_images">Папка із зображеннями</string>
<string name="importing_manga">Імпорт манґи</string>
<string name="import_completed">Імпорт завершено</string>
<string name="import_completed_hint">Ви можете видалити оригінальний файл зі сховища, щоб заощадити місце</string>
<string name="import_will_start_soon">Імпорт почнеться незабаром</string>
<string name="feed">Стрічка</string>
<string name="reader_info_pattern">Розд. %1$d/%2$d Стор. %3$d/%4$d</string>
<string name="seconds_pattern">%s с</string>
<string name="account_already_exists">Обліковий запис уже існує</string>
<string name="sync_title">Синхронізуйте ваші дані</string>
<string name="canceled">Скасовано</string>
<string name="confirm_exit">Натисніть Назад ще раз, щоб вийти</string>
<string name="sync">Синхронізація</string>
<string name="clear_all_history">Очистити всю історію</string>
<string name="last_2_hours">Останні 2 години</string>
<string name="exit_confirmation_summary">Двічі натисніть Назад, щоб вийти з програми</string>
<string name="exit_confirmation">Підтвердження виходу</string>
<string name="enter_email_text">Введіть електронну пошту, щоб продовжити</string>
<string name="removed_from_favourites">Видалено з уподобань</string>
<string name="other_cache">Інший кеш</string>
<string name="memory_usage_pattern">%s - %s</string>
</resources> </resources>

@ -6,7 +6,7 @@
<string name="url_twitter" translatable="false">https://twitter.com/kotatsuapp</string> <string name="url_twitter" translatable="false">https://twitter.com/kotatsuapp</string>
<string name="url_reddit" translatable="false">https://reddit.com/user/kotatsuapp</string> <string name="url_reddit" translatable="false">https://reddit.com/user/kotatsuapp</string>
<string name="url_weblate" translatable="false">https://hosted.weblate.org/engage/kotatsu</string> <string name="url_weblate" translatable="false">https://hosted.weblate.org/engage/kotatsu</string>
<string name="email_error_report" translatable="false">kotatsu@waifu.club</string> <string name="url_error_report" translatable="false">http://86.57.183.214:8082/report</string>
<string name="account_type_sync" translatable="false">org.kotatsu.sync</string> <string name="account_type_sync" translatable="false">org.kotatsu.sync</string>
<string name="url_sync_server" translatable="false">http://86.57.183.214:8081</string> <string name="url_sync_server" translatable="false">http://86.57.183.214:8081</string>
<string-array name="values_theme" translatable="false"> <string-array name="values_theme" translatable="false">

@ -35,6 +35,8 @@
<dimen name="search_suggestions_manga_spacing">4dp</dimen> <dimen name="search_suggestions_manga_spacing">4dp</dimen>
<dimen name="bottom_sheet_width">0dp</dimen> <dimen name="bottom_sheet_width">0dp</dimen>
<dimen name="bottom_sheet_handle_size_min">16dp</dimen>
<dimen name="bottom_sheet_handle_size_max">24dp</dimen>
<dimen name="dialog_radius">8dp</dimen> <dimen name="dialog_radius">8dp</dimen>
<dimen name="appwidget_corner_radius_inner">8dp</dimen> <dimen name="appwidget_corner_radius_inner">8dp</dimen>

@ -376,5 +376,10 @@
<string name="import_completed_hint">You can delete the original file from storage to save space</string> <string name="import_completed_hint">You can delete the original file from storage to save space</string>
<string name="import_will_start_soon">Import will start soon</string> <string name="import_will_start_soon">Import will start soon</string>
<string name="feed">Feed</string> <string name="feed">Feed</string>
<string name="manga_error_description_pattern">Error details:&lt;br>&lt;tt>%1$s&lt;/tt>&lt;br>&lt;br>1. Try to &lt;a href="%2$s">open manga in a web browser&lt;/a> to ensure it is available on its source&lt;br>2. If it is available, send an error report to the developers.</string>
<string name="history_shortcuts">Show recent manga shortcuts</string>
<string name="history_shortcuts_summary">Make recent manga available by long pressing on application icon</string>
<string name="reader_control_ltr_summary">Tap on the right edge or pressing the right key always switches to the next page</string>
<string name="reader_control_ltr">Ergonomic reader control</string>
<string name="color_filter">Color filter</string> <string name="color_filter">Color filter</string>
</resources> </resources>

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

Loading…
Cancel
Save