Compare commits

...

37 Commits

Author SHA1 Message Date
fc4b6eb1af Respect display cutout in reader bar 2022-08-27 18:17:01 +03:00
2aaaf2f4a2 Fix pages thumbnails for webtoons 2022-08-27 17:55:34 +03:00
92aa96a644 Color filter support in reader 2022-08-27 16:37:46 +03:00
6a0a4023ad Merge branch 'devel' into feature/colorfilter 2022-08-27 13:02:05 +03:00
8569610e52 Fix BottomSheet blinking 2022-08-27 12:47:42 +03:00
51ffa4d469 Merge branch 'master' into devel 2022-08-27 10:55:08 +03:00
e4c4d2bbf0 Fix crashes 2022-08-27 09:38:54 +03:00
35d6f1fb34 Local list sort order (wip) #217 2022-08-26 13:21:25 +03:00
edac5fda8c Reader control direction depends on mode #214 2022-08-26 12:27:57 +03:00
040d3e4433 Reader control direction depends on mode #214 2022-08-26 12:13:18 +03:00
011dd4c069 Option to disable dynamic shortcuts 2022-08-26 10:52:09 +03:00
b4f93fc0a5 Fix showing reader control by long press 2022-08-26 10:09:48 +03:00
e0d74ba2a9 Fix showing reader control by long press 2022-08-26 10:07:07 +03:00
935826617e Upgrade targetSdk to 33 2022-08-24 11:14:22 +03:00
b9f2effb86 Fix some crashes 2022-08-24 09:13:36 +03:00
5c86de555a Merge branch 'master' into devel 2022-08-23 09:35:16 +03:00
c4e7807d18 Update version 2022-08-23 09:23:34 +03:00
78dd0588ce Merge branch 'devel' of github.com:KotatsuApp/Kotatsu into devel 2022-08-22 12:09:16 +03:00
2d73894880 Fix details ui 2022-08-22 12:08:45 +03:00
8ac19e557b Support rounded corners for reader info bar 2022-08-21 15:11:36 +03:00
f677a75ad1 Restore state of ssiv 2022-08-20 09:55:50 +03:00
8e55a4d824 Fix Shikimori authToken refreshing 2022-08-19 11:46:05 +03:00
6b5d8ff0f1 Fix Shikimori authToken refreshing 2022-08-19 11:45:43 +03:00
0c7da53349 Improve details ui 2022-08-19 11:25:36 +03:00
2a3cc11728 Save reader state on idle 2022-08-19 10:22:16 +03:00
a806634bc0 Add text outline to reader info bar 2022-08-19 10:03:13 +03:00
6879d046f8 Adjust reader info bar insets 2022-08-18 17:00:59 +03:00
0248f84ca0 Fix in-app update checking 2022-08-18 16:13:35 +03:00
ff0706dae5 Change acra sender to http 2022-08-18 15:43:12 +03:00
b73fe0398f Highlight new records in feed 2022-08-18 12:33:17 +03:00
f78ae4a818 Make BottomSheet`s handle fit top window inset 2022-08-18 11:35:21 +03:00
072f6d8c69 Show dialog on manga loading error 2022-08-18 11:07:20 +03:00
68b68eb4c5 Handle unknown manga sources 2022-08-18 10:15:34 +03:00
7f95eead50 Merge branch 'master' into devel 2022-08-18 09:18:17 +03:00
Dpper
1935de8f20 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (376 of 376 strings)

Co-authored-by: Dpper <ruslan20020401@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2022-08-18 09:15:50 +03:00
c1e9fde6e8 Update parsers and version 2022-08-17 16:51:03 +03:00
d853bb2c62 Pages color filter implementation draft 2022-08-13 16:27:53 +03:00
131 changed files with 2134 additions and 698 deletions

View File

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

View File

@@ -10,4 +10,7 @@
}
-keep public class ** extends org.koitharu.kotatsu.base.ui.BaseFragment
-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

View File

@@ -17,6 +17,7 @@
<uses-permission android:name="android.permission.READ_SYNC_STATS" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name="org.koitharu.kotatsu.KotatsuApp"
@@ -132,11 +133,14 @@
<activity
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity"
android:label="@string/sync" />
<activity
android:name="org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity"
android:label="@string/color_correction" />
<service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
android:stopWithTask="false"
android:foregroundServiceType="dataSync" />
android:foregroundServiceType="dataSync"
android:stopWithTask="false" />
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
<service android:name="org.koitharu.kotatsu.local.ui.ImportService" />
<service

View File

@@ -15,9 +15,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.acra.ReportField
import org.acra.config.dialog
import org.acra.config.mailSender
import org.acra.config.httpSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.PagesCache
@@ -59,16 +60,24 @@ class KotatsuApp : Application(), Configuration.Provider {
super.attachBaseContext(base)
initAcra {
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(
ReportField.PACKAGE_NAME,
ReportField.APP_VERSION_CODE,
ReportField.APP_VERSION_NAME,
ReportField.ANDROID_VERSION,
ReportField.PHONE_MODEL,
ReportField.CRASH_CONFIGURATION,
ReportField.STACK_TRACE,
ReportField.CUSTOM_DATA,
ReportField.CRASH_CONFIGURATION,
ReportField.SHARED_PREFERENCES,
)
dialog {
@@ -78,11 +87,6 @@ class KotatsuApp : Application(), Configuration.Provider {
resIcon = R.drawable.ic_alert_outline
resTheme = android.R.style.Theme_Material_Light_Dialog_Alert
}
mailSender {
mailTo = getString(R.string.email_error_report)
reportAsFile = true
reportFileName = "stacktrace.txt"
}
}
}

View File

@@ -10,6 +10,9 @@ import java.util.zip.ZipFile
import javax.inject.Inject
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -23,6 +26,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
private const val MIN_WEBTOON_RATIO = 2
@@ -31,15 +35,22 @@ class MangaDataRepository @Inject constructor(
private val db: MangaDatabase,
) {
suspend fun savePreferences(manga: Manga, mode: ReaderMode) {
val tags = manga.tags.toEntities()
suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) {
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga.toEntity(), tags)
storeManga(manga)
val entity = db.preferencesDao.find(manga.id) ?: newEntity(manga.id)
db.preferencesDao.upsert(entity.copy(mode = mode.id))
}
}
suspend fun saveColorFilter(manga: Manga, colorFilter: ReaderColorFilter?) {
db.withTransaction {
storeManga(manga)
val entity = db.preferencesDao.find(manga.id) ?: newEntity(manga.id)
db.preferencesDao.upsert(
MangaPrefsEntity(
mangaId = manga.id,
mode = mode.id,
entity.copy(
cfBrightness = colorFilter?.brightness ?: 0f,
cfContrast = colorFilter?.contrast ?: 0f,
),
)
}
@@ -49,6 +60,16 @@ class MangaDataRepository @Inject constructor(
return db.preferencesDao.find(mangaId)?.let { ReaderMode.valueOf(it.mode) }
}
suspend fun getColorFilter(mangaId: Long): ReaderColorFilter? {
return db.preferencesDao.find(mangaId)?.getColorFilterOrNull()
}
fun observeColorFilter(mangaId: Long): Flow<ReaderColorFilter?> {
return db.preferencesDao.observe(mangaId)
.map { it?.getColorFilterOrNull() }
.distinctUntilChanged()
}
suspend fun findMangaById(mangaId: Long): Manga? {
return db.mangaDao.find(mangaId)?.toManga()
}
@@ -71,6 +92,14 @@ class MangaDataRepository @Inject constructor(
return db.tagsDao.findTags(source.name).toMangaTags()
}
private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? {
return if (cfBrightness != 0f || cfContrast != 0f) {
ReaderColorFilter(cfBrightness, cfContrast)
} else {
null
}
}
/**
* Automatic determine type of manga by page size
* @return ReaderMode.WEBTOON if page is wide
@@ -104,6 +133,13 @@ class MangaDataRepository @Inject constructor(
return size.width * MIN_WEBTOON_RATIO < size.height
}
private fun newEntity(mangaId: Long) = MangaPrefsEntity(
mangaId = mangaId,
mode = -1,
cfBrightness = 0f,
cfContrast = 0f,
)
companion object {
suspend fun getImageMimeType(file: File): String? = runInterruptible(Dispatchers.IO) {

View File

@@ -5,6 +5,8 @@ import android.net.Uri
import android.os.Bundle
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
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(
val manga: Manga?,
@@ -13,15 +15,15 @@ class MangaIntent private constructor(
) {
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,
uri = intent?.data
uri = intent?.data,
)
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,
uri = null
uri = null,
)
companion object {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,7 +35,7 @@ import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
const val DATABASE_VERSION = 14
const val DATABASE_VERSION = 15
@Database(
entities = [
@@ -86,6 +86,7 @@ val databaseMigrations: Array<Migration>
Migration11To12(),
Migration12To13(),
Migration13To14(),
Migration14To15(),
)
fun MangaDatabase(context: Context): MangaDatabase = Room

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.db.dao
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
@Dao
@@ -9,6 +10,9 @@ abstract class PreferencesDao {
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
abstract suspend fun find(mangaId: Long): MangaPrefsEntity?
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?>
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(pref: MangaPrefsEntity): Long
@@ -21,4 +25,4 @@ abstract class PreferencesDao {
insert(pref)
}
}
}
}

View File

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

View File

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

View File

@@ -12,12 +12,15 @@ import androidx.room.PrimaryKey
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
)
]
onDelete = ForeignKey.CASCADE,
),
],
)
class MangaPrefsEntity(
data class MangaPrefsEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "mode") val mode: Int
)
@ColumnInfo(name = "manga_id")
val mangaId: Long,
@ColumnInfo(name = "mode") val mode: Int,
@ColumnInfo(name = "cf_brightness") val cfBrightness: Float,
@ColumnInfo(name = "cf_contrast") val cfContrast: Float,
)

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration14To15 : Migration(14, 15) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_brightness` REAL NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_contrast` REAL NOT NULL DEFAULT 0")
}
}

View File

@@ -11,8 +11,10 @@ import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
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()) {
return null
return@withContext null
}
return runCatching {
runCatching {
val currentVersion = VersionId(BuildConfig.VERSION_NAME)
val available = getAvailableVersions().asArrayList()
available.sortBy { it.versionId }

View File

@@ -2,10 +2,12 @@ package org.koitharu.kotatsu.core.os
import android.app.ActivityManager
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.ShortcutManager
import android.media.ThumbnailUtils
import android.os.Build
import android.util.Size
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
@@ -22,6 +24,7 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository
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.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity
@@ -35,13 +38,18 @@ class ShortcutsUpdater @Inject constructor(
private val coil: ImageLoader,
private val historyRepository: HistoryRepository,
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 var shortcutsUpdateJob: Job? = null
override fun onInvalidated(tables: MutableSet<String>) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
init {
settings.subscribe(this)
}
override fun onInvalidated(tables: Set<String>) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1 || !settings.isDynamicShortcutsEnabled) {
return
}
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 {
return ShortcutManagerCompat.requestPinShortcut(
context,
@@ -64,6 +82,15 @@ class ShortcutsUpdater @Inject constructor(
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 {
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity)
@@ -74,6 +101,15 @@ class ShortcutsUpdater @Inject constructor(
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 {
val icon = runCatching {
val bmp = coil.execute(

View File

@@ -11,6 +11,12 @@ import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors
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.core.model.ZoomMode
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.putEnumValue
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
class AppSettings @Inject constructor(@ApplicationContext context: Context) {
@@ -65,6 +65,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val readerPageSwitch: Set<String>
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
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
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)
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
get() = prefs.getBoolean(KEY_TRACKER_ENABLED, true)
@@ -141,6 +137,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isExitConfirmationEnabled: Boolean
get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false)
val isDynamicShortcutsEnabled: Boolean
get() = prefs.getBoolean(KEY_SHORTCUTS, true)
var sourcesOrder: List<String>
get() = prefs.getString(KEY_SOURCES_ORDER, null)
?.split('|')
@@ -324,10 +323,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_INCOGNITO_MODE = "incognito"
const val KEY_SYNC = "sync"
const val KEY_READER_BAR = "reader_bar"
const val KEY_SHORTCUTS = "dynamic_shortcuts"
const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
// About
const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_UPDATE_AUTO = "app_update_auto"
const val KEY_APP_TRANSLATION = "about_app_translation"
private const val NETWORK_NEVER = 0

View File

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

View File

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

View File

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

View File

@@ -70,6 +70,7 @@ class DetailsFragment :
super.onViewCreated(view, savedInstanceState)
binding.textViewAuthor.setOnClickListener(this)
binding.imageViewCover.setOnClickListener(this)
binding.infoLayout.textViewSource.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.chipsTags.onChipClickListener = this
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 -> {
startActivity(
ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }),

View File

@@ -28,4 +28,4 @@ fun MangaChapter.toListItem(
uploadDateMs = uploadDate,
dateFormat = dateFormat,
)
}
}

View File

@@ -14,6 +14,7 @@ import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import com.google.android.material.R as materialR
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R
@@ -25,6 +26,7 @@ import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getSerializableCompat
@AndroidEntryPoint
class FavouritesCategoryEditActivity :
@@ -46,7 +48,7 @@ class FavouritesCategoryEditActivity :
setContentView(ActivityCategoryEditBinding.inflate(layoutInflater))
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(com.google.android.material.R.drawable.abc_ic_clear_material)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
initSortSpinner()
binding.buttonDone.setOnClickListener(this)
@@ -69,8 +71,8 @@ class FavouritesCategoryEditActivity :
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
val order = savedInstanceState.getSerializable(KEY_SORT_ORDER)
if (order != null && order is SortOrder) {
val order = savedInstanceState.getSerializableCompat<SortOrder>(KEY_SORT_ORDER)
if (order != null) {
selectedSortOrder = order
}
}

View File

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

View File

@@ -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.model.*
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.onFirst
@@ -60,8 +60,8 @@ class HistoryListViewModel @Inject constructor(
}.onFirst {
loadingCounter.decrement()
}.catch {
it.toErrorState(canRetry = false)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
emit(listOf(it.toErrorState(canRetry = false)))
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
override fun onRefresh() = Unit

View File

@@ -56,17 +56,17 @@ class ReadingProgressView @JvmOverloads constructor(
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) {
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) {
val currentDrawable = peekProgressDrawable()

View File

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

View File

@@ -6,6 +6,7 @@ import android.view.View
import android.widget.TextView
import androidx.annotation.StyleRes
import androidx.core.view.updateLayoutParams
import androidx.core.widget.TextViewCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
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)
update()
}
override fun onViewDetachedFromWindow(v: View?) {
override fun onViewDetachedFromWindow(v: View) {
settings.unsubscribe(this)
}
@@ -77,7 +78,7 @@ class ItemSizeResolver(resources: Resources, private val settings: AppSettings)
}
if (textAppearanceResId != prevTextAppearance) {
prevTextAppearance = textAppearanceResId
setTextAppearance(textAppearanceResId)
TextViewCompat.setTextAppearance(this, textAppearanceResId)
requestLayout()
}
}

View File

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

View File

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

View File

@@ -6,18 +6,21 @@ import android.os.Bundle
import android.view.*
import androidx.appcompat.widget.SearchView
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.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
import org.koitharu.kotatsu.utils.ext.isScrolledToTop
import org.koitharu.kotatsu.utils.ext.parentFragmentViewModels
class FilterBottomSheet :
BaseBottomSheet<SheetFilterBinding>(),
MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener,
DialogInterface.OnKeyListener {
DialogInterface.OnKeyListener,
AsyncListDiffer.ListListener<FilterItem> {
private val viewModel by parentFragmentViewModels<RemoteListViewModel>()
@@ -33,13 +36,13 @@ class FilterBottomSheet :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val adapter = FilterAdapter(viewModel)
val adapter = FilterAdapter(viewModel, this)
binding.recyclerView.adapter = adapter
viewModel.filterItems.observe(viewLifecycleOwner, adapter::setItems)
initOptionsMenu()
}
override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
setExpanded(isExpanded = true, isLocked = true)
return true
}
@@ -71,6 +74,12 @@ class FilterBottomSheet :
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() {
binding.headerBar.toolbar.inflateMenu(R.menu.opt_filter)
val searchMenuItem = binding.headerBar.toolbar.menu.findItem(R.id.action_search)

View File

@@ -63,6 +63,10 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
x.tags.containsAll(tags)
}
}
when (sortOrder) {
SortOrder.ALPHABETICAL -> list.sortBy { it.title }
SortOrder.RATING -> list.sortBy { it.rating }
}
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

View File

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

View File

@@ -1,5 +1,8 @@
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.util.SparseIntArray
import android.view.MenuItem
@@ -7,6 +10,7 @@ import android.view.View
import androidx.activity.result.ActivityResultCallback
import androidx.activity.viewModels
import androidx.appcompat.view.ActionMode
import androidx.core.app.ActivityCompat
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
@@ -24,7 +28,6 @@ import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.widgets.SlidingBottomNavigationView
@@ -291,12 +294,11 @@ class MainActivity :
TrackWorker.setup(applicationContext)
SuggestionsWorker.setup(applicationContext)
}
requestNotificationsPermission()
when {
!settings.isSourcesSelected -> OnboardDialogFragment.showWelcome(supportFragmentManager)
settings.newSources.isNotEmpty() -> NewSourcesDialogFragment.show(supportFragmentManager)
}
yield()
// TODO get<SyncController>().requestFullSyncAndGc(get())
}
}
@@ -347,6 +349,15 @@ class MainActivity :
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?> {
override fun onActivityResult(result: String?) {

View File

@@ -4,33 +4,36 @@ import android.util.SparseIntArray
import androidx.core.util.set
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
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.github.AppUpdateRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.domain.HistoryRepository
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.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
private val historyRepository: HistoryRepository,
private val settings: AppSettings,
private val appUpdateRepository: AppUpdateRepository,
private val trackingRepository: TrackingRepository,
syncController: SyncController,
database: MangaDatabase,
) : BaseViewModel() {
val onOpenReader = SingleLiveEvent<Manga>()
val isResumeEnabled = historyRepository
.observeHasItems()
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false)
.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false)
val counters = combine(
appUpdateRepository.observeAvailableUpdate(),
@@ -43,9 +46,12 @@ class MainViewModel @Inject constructor(
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, SparseIntArray(0))
init {
launchJob(Dispatchers.Default) {
launchJob {
appUpdateRepository.fetchUpdate()
}
launchJob {
syncController.requestFullSyncAndGc(database)
}
}
fun openLastReader() {

View File

@@ -6,6 +6,7 @@ import android.content.Intent
import android.os.Bundle
import javax.inject.Inject
import javax.inject.Singleton
import org.acra.dialog.CrashReportDialog
import org.koitharu.kotatsu.core.prefs.AppSettings
@Singleton
@@ -14,7 +15,7 @@ class AppProtectHelper @Inject constructor(private val settings: AppSettings) :
private var isUnlocked = settings.appPassword.isNullOrEmpty()
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)
activity.intent?.let {
sourceIntent.putExtras(it)

View File

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

View File

@@ -0,0 +1,52 @@
package org.koitharu.kotatsu.reader.domain
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
class ReaderColorFilter(
val brightness: Float,
val contrast: Float,
) {
val isEmpty: Boolean
get() = brightness == 0f && contrast == 0f
fun toColorFilter(): ColorMatrixColorFilter {
val cm = ColorMatrix()
val scale = brightness + 1f
cm.setScale(scale, scale, scale, 1f)
cm.setContrast(contrast)
return ColorMatrixColorFilter(cm)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ReaderColorFilter
if (brightness != other.brightness) return false
if (contrast != other.contrast) return false
return true
}
override fun hashCode(): Int {
var result = brightness.hashCode()
result = 31 * result + contrast.hashCode()
return result
}
private fun ColorMatrix.setContrast(contrast: Float) {
val scale = contrast + 1f
val translate = (-.5f * scale + .5f) * 255f
val array = floatArrayOf(
scale, 0f, 0f, 0f, translate,
0f, scale, 0f, 0f, translate,
0f, 0f, scale, 0f, translate,
0f, 0f, 0f, 1f, 0f,
)
val matrix = ColorMatrix(array)
postConcat(matrix)
}
}

View File

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

View File

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

View File

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

View File

@@ -1,33 +1,39 @@
package org.koitharu.kotatsu.reader.ui
import android.content.SharedPreferences
import android.view.KeyEvent
import android.view.SoundEffectConstants
import android.view.View
import androidx.lifecycle.LifecycleCoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
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
class ReaderControlDelegate(
scope: LifecycleCoroutineScope,
settings: AppSettings,
private val settings: AppSettings,
private val listener: OnInteractionListener,
) {
owner: LifecycleOwner,
) : DefaultLifecycleObserver, SharedPreferences.OnSharedPreferenceChangeListener {
private var isTapSwitchEnabled: Boolean = true
private var isVolumeKeysSwitchEnabled: Boolean = false
private var isReaderTapsAdaptive: Boolean = true
init {
settings.observeAsFlow(AppSettings.KEY_READER_SWITCHERS) { readerPageSwitch }
.flowOn(Dispatchers.Default)
.onEach {
isTapSwitchEnabled = AppSettings.PAGE_SWITCH_TAPS in it
isVolumeKeysSwitchEnabled = AppSettings.PAGE_SWITCH_VOLUME_KEYS in it
}.launchIn(scope)
owner.lifecycle.addObserver(this)
settings.subscribe(this)
updateSettings()
}
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) {
@@ -41,7 +47,7 @@ class ReaderControlDelegate(
view.playSoundEffect(SoundEffectConstants.NAVIGATION_UP)
}
GridTouchHelper.AREA_LEFT -> if (isTapSwitchEnabled) {
listener.switchPageBy(-1)
listener.switchPageBy(if (isReaderTapsReversed()) 1 else -1)
view.playSoundEffect(SoundEffectConstants.NAVIGATION_LEFT)
}
GridTouchHelper.AREA_BOTTOM -> if (isTapSwitchEnabled) {
@@ -49,7 +55,7 @@ class ReaderControlDelegate(
view.playSoundEffect(SoundEffectConstants.NAVIGATION_DOWN)
}
GridTouchHelper.AREA_RIGHT -> if (isTapSwitchEnabled) {
listener.switchPageBy(1)
listener.switchPageBy(if (isReaderTapsReversed()) -1 else 1)
view.playSoundEffect(SoundEffectConstants.NAVIGATION_RIGHT)
}
}
@@ -72,19 +78,25 @@ class ReaderControlDelegate(
KeyEvent.KEYCODE_PAGE_DOWN,
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_RIGHT,
-> {
listener.switchPageBy(1)
true
}
KeyEvent.KEYCODE_DPAD_RIGHT -> {
listener.switchPageBy(if (isReaderTapsReversed()) -1 else 1)
true
}
KeyEvent.KEYCODE_PAGE_UP,
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP,
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_LEFT,
-> {
listener.switchPageBy(-1)
true
}
KeyEvent.KEYCODE_DPAD_LEFT -> {
listener.switchPageBy(if (isReaderTapsReversed()) 1 else -1)
true
}
KeyEvent.KEYCODE_DPAD_CENTER -> {
listener.toggleUiVisibility()
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 {
val readerMode: ReaderMode?
fun switchPageBy(delta: Int)
fun toggleUiVisibility()

View File

@@ -8,17 +8,22 @@ import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.icu.text.SimpleDateFormat
import android.util.AttributeSet
import android.view.View
import android.view.WindowInsets
import androidx.annotation.AttrRes
import androidx.core.graphics.ColorUtils
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.google.android.material.R as materialR
import java.text.SimpleDateFormat
import java.util.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
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
class ReaderInfoBarView @JvmOverloads constructor(
@@ -29,23 +34,48 @@ class ReaderInfoBarView @JvmOverloads constructor(
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val textBounds = Rect()
private val inset = context.resources.resolveDp(2f)
private val timeFormat = SimpleDateFormat.getTimeInstance(SimpleDateFormat.SHORT)
private val timeReceiver = TimeReceiver()
private var insetLeft: Int = 0
private var insetRight: Int = 0
private var insetTop: Int = 0
private var cutoutInsetLeft = 0
private var cutoutInsetRight = 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 text: String = ""
private val innerHeight
get() = height - inset - inset - paddingTop - paddingBottom
get() = height - paddingTop - paddingBottom - insetTop
private val innerWidth
get() = width - inset - inset - paddingLeft - paddingRight
get() = width - paddingLeft - paddingRight - insetLeft - insetRight
init {
paint.color = ColorUtils.setAlphaComponent(
context.getThemeColor(materialR.attr.colorOnSurface, Color.BLACK),
160,
paint.strokeWidth = context.resources.resolveDp(2f)
val insetCorner = getSystemUiDimensionOffset("rounded_corner_content_padding")
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,19 +83,34 @@ class ReaderInfoBarView @JvmOverloads constructor(
super.onDraw(canvas)
val ty = innerHeight / 2f + textBounds.height() / 2f - textBounds.bottom
paint.textAlign = Paint.Align.LEFT
canvas.drawText(text, paddingLeft + inset, paddingTop + inset + ty, paint)
canvas.drawTextOutline(
text,
(paddingLeft + insetLeft + cutoutInsetLeft).toFloat(),
paddingTop + insetTop + ty,
)
paint.textAlign = Paint.Align.RIGHT
canvas.drawText(timeText, width - paddingRight - inset, paddingTop + inset + ty, paint)
canvas.drawTextOutline(
timeText,
(width - paddingRight - insetRight - cutoutInsetRight).toFloat(),
paddingTop + insetTop + ty,
)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
updateCutoutInsets(ViewCompat.getRootWindowInsets(this))
updateTextSize()
}
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
updateCutoutInsets(WindowInsetsCompat.toWindowInsetsCompat(insets))
return super.onApplyWindowInsets(insets)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
context.registerReceiver(timeReceiver, IntentFilter(Intent.ACTION_TIME_TICK))
updateCutoutInsets(ViewCompat.getRootWindowInsets(this))
}
override fun onDetachedFromWindow() {
@@ -103,6 +148,29 @@ class ReaderInfoBarView @JvmOverloads constructor(
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 fun updateCutoutInsets(insetsCompat: WindowInsetsCompat?) {
val cutouts = (insetsCompat ?: return).displayCutout?.boundingRects.orEmpty()
cutoutInsetLeft = 0
cutoutInsetRight = 0
for (rect in cutouts) {
if (rect.left <= paddingLeft) {
cutoutInsetLeft += rect.width()
}
if (rect.right >= width - paddingRight) {
cutoutInsetRight += rect.width()
}
}
}
private inner class TimeReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
@@ -110,4 +178,13 @@ class ReaderInfoBarView @JvmOverloads constructor(
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)
}

View File

@@ -31,6 +31,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.data.filterChapters
import org.koitharu.kotatsu.reader.domain.ChaptersLoader
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
@@ -86,6 +87,14 @@ class ReaderViewModel @AssistedInject constructor(
valueProducer = { isReaderBarEnabled },
)
val readerSettings = ReaderSettings(
parentScope = viewModelScope,
settings = settings,
colorFilterFlow = mangaData.flatMapLatest {
if (it == null) flowOf(null) else dataRepository.observeColorFilter(it.id)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null),
)
val isScreenshotsBlockEnabled = combine(
mangaData,
settings.observeAsFlow(AppSettings.KEY_SCREENSHOTS_POLICY) { screenshotsPolicy },
@@ -94,8 +103,6 @@ class ReaderViewModel @AssistedInject constructor(
(policy == ScreenshotsPolicy.BLOCK_NSFW && manga != null && manga.isNsfw)
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false)
val onZoomChanged = SingleLiveEvent<Unit>()
val isBookmarkAdded: LiveData<Boolean> = currentState.flatMapLatest { state ->
val manga = mangaData.value
if (state == null || manga == null) {
@@ -108,7 +115,6 @@ class ReaderViewModel @AssistedInject constructor(
init {
loadImpl()
subscribeToSettings()
}
override fun onCleared() {
@@ -124,7 +130,7 @@ class ReaderViewModel @AssistedInject constructor(
fun switchMode(newMode: ReaderMode) {
launchJob {
val manga = checkNotNull(mangaData.value)
dataRepository.savePreferences(
dataRepository.saveReaderMode(
manga = manga,
mode = newMode,
)
@@ -300,13 +306,6 @@ class ReaderViewModel @AssistedInject constructor(
}
}
private fun subscribeToSettings() {
settings.observe()
.onEach { key ->
if (key == AppSettings.KEY_ZOOM_MODE) onZoomChanged.postCall(Unit)
}.launchIn(viewModelScope + Dispatchers.Default)
}
private fun <T> List<T>.trySublist(fromIndex: Int, toIndex: Int): List<T> {
val fromIndexBounded = fromIndex.coerceAtMost(lastIndex)
val toIndexBounded = toIndex.coerceIn(fromIndexBounded, lastIndex)
@@ -331,7 +330,7 @@ class ReaderViewModel @AssistedInject constructor(
val isWebtoon = dataRepository.determineMangaIsWebtoon(repo, pages)
if (isWebtoon) ReaderMode.WEBTOON else defaultMode
}.onSuccess {
dataRepository.savePreferences(manga, it)
dataRepository.saveReaderMode(manga, it)
}.onFailure {
it.printStackTraceDebug()
}.getOrDefault(defaultMode)

View File

@@ -0,0 +1,151 @@
package org.koitharu.kotatsu.reader.ui.colorfilter
import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import coil.size.ViewSizeResolver
import com.google.android.material.R as materialR
import com.google.android.material.slider.LabelFormatter
import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPages
import org.koitharu.kotatsu.databinding.ActivityColorFilterBinding
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import org.koitharu.kotatsu.utils.ext.*
@AndroidEntryPoint
class ColorFilterConfigActivity :
BaseActivity<ActivityColorFilterBinding>(),
Slider.OnChangeListener,
View.OnClickListener {
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var viewModelFactory: ColorFilterConfigViewModel.Factory
private val viewModel: ColorFilterConfigViewModel by assistedViewModels {
viewModelFactory.create(
manga = checkNotNull(intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga),
page = checkNotNull(intent.getParcelableExtraCompat<ParcelableMangaPages>(EXTRA_PAGES)?.pages?.firstOrNull()),
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityColorFilterBinding.inflate(layoutInflater))
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
binding.sliderBrightness.addOnChangeListener(this)
binding.sliderContrast.addOnChangeListener(this)
val formatter = PercentLabelFormatter(resources)
binding.sliderContrast.setLabelFormatter(formatter)
binding.sliderBrightness.setLabelFormatter(formatter)
binding.buttonDone.setOnClickListener(this)
binding.buttonReset.setOnClickListener(this)
onBackPressedDispatcher.addCallback(ColorFilterConfigBackPressedDispatcher(this, viewModel))
viewModel.colorFilter.observe(this, this::onColorFilterChanged)
viewModel.isLoading.observe(this, this::onLoadingChanged)
viewModel.preview.observe(this, this::onPreviewChanged)
viewModel.onDismiss.observe(this) {
finishAfterTransition()
}
}
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
if (fromUser) {
when (slider.id) {
R.id.slider_brightness -> viewModel.setBrightness(value)
R.id.slider_contrast -> viewModel.setContrast(value)
}
}
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_done -> viewModel.save()
R.id.button_reset -> viewModel.reset()
}
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.root.updatePadding(
left = insets.left,
right = insets.right,
)
binding.scrollView.updatePadding(
bottom = insets.bottom,
)
binding.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
}
private fun onColorFilterChanged(readerColorFilter: ReaderColorFilter?) {
binding.sliderBrightness.value = readerColorFilter?.brightness ?: 0f
binding.sliderContrast.value = readerColorFilter?.contrast ?: 0f
binding.imageViewAfter.colorFilter = readerColorFilter?.toColorFilter()
}
private fun onPreviewChanged(preview: MangaPage?) {
if (preview == null) return
ImageRequest.Builder(this@ColorFilterConfigActivity)
.data(preview.url)
.referer(preview.referer)
.scale(Scale.FILL)
.decodeRegion()
.error(R.drawable.ic_error_placeholder)
.size(ViewSizeResolver(binding.imageViewBefore))
.allowRgb565(false)
.target(ShadowViewTarget(binding.imageViewBefore, binding.imageViewAfter))
.enqueueWith(coil)
}
private fun onLoadingChanged(isLoading: Boolean) {
binding.sliderContrast.isEnabled = !isLoading
binding.sliderBrightness.isEnabled = !isLoading
binding.buttonDone.isEnabled = !isLoading
}
private class PercentLabelFormatter(resources: Resources) : LabelFormatter {
private val pattern = resources.getString(R.string.percent_string_pattern)
override fun getFormattedValue(value: Float): String {
val percent = ((value + 1f) * 100).format(0)
return pattern.format(percent)
}
}
companion object {
private const val EXTRA_PAGES = "pages"
private const val EXTRA_MANGA = "manga_id"
fun newIntent(context: Context, manga: Manga, page: MangaPage) =
Intent(context, ColorFilterConfigActivity::class.java)
.putExtra(EXTRA_MANGA, ParcelableManga(manga, false))
.putExtra(EXTRA_PAGES, ParcelableMangaPages(listOf(page)))
}
}

View File

@@ -0,0 +1,39 @@
package org.koitharu.kotatsu.reader.ui.colorfilter
import android.content.Context
import android.content.DialogInterface
import androidx.activity.OnBackPressedCallback
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
class ColorFilterConfigBackPressedDispatcher(
private val context: Context,
private val viewModel: ColorFilterConfigViewModel,
) : OnBackPressedCallback(true), DialogInterface.OnClickListener {
override fun handleOnBackPressed() {
if (viewModel.isChanged) {
showConfirmation()
} else {
viewModel.onDismiss.call(Unit)
}
}
override fun onClick(dialog: DialogInterface, which: Int) {
when (which) {
DialogInterface.BUTTON_NEGATIVE -> viewModel.onDismiss.call(Unit)
DialogInterface.BUTTON_NEUTRAL -> dialog.dismiss()
DialogInterface.BUTTON_POSITIVE -> viewModel.save()
}
}
private fun showConfirmation() {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.color_correction)
.setMessage(R.string.text_unsaved_changes_prompt)
.setNegativeButton(R.string.discard, this)
.setNeutralButton(android.R.string.cancel, this)
.setPositiveButton(R.string.save, this)
.show()
}
}

View File

@@ -0,0 +1,74 @@
package org.koitharu.kotatsu.reader.ui.colorfilter
import androidx.lifecycle.MutableLiveData
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import org.koitharu.kotatsu.utils.SingleLiveEvent
class ColorFilterConfigViewModel @AssistedInject constructor(
@Assisted private val manga: Manga,
@Assisted page: MangaPage,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val mangaDataRepository: MangaDataRepository,
) : BaseViewModel() {
private var initialColorFilter: ReaderColorFilter? = null
val colorFilter = MutableLiveData<ReaderColorFilter?>(null)
val onDismiss = SingleLiveEvent<Unit>()
val preview = MutableLiveData<MangaPage?>(null)
val isChanged: Boolean
get() = colorFilter.value != initialColorFilter
init {
launchLoadingJob {
initialColorFilter = mangaDataRepository.getColorFilter(manga.id)
colorFilter.value = initialColorFilter
}
launchLoadingJob {
val repository = mangaRepositoryFactory.create(page.source)
val url = repository.getPageUrl(page)
preview.value = MangaPage(
id = page.id,
url = url,
referer = page.referer,
preview = page.preview,
source = page.source,
)
}
}
fun setBrightness(brightness: Float) {
val cf = colorFilter.value
colorFilter.value = ReaderColorFilter(brightness, cf?.contrast ?: 0f).takeUnless { it.isEmpty }
}
fun setContrast(contrast: Float) {
val cf = colorFilter.value
colorFilter.value = ReaderColorFilter(cf?.brightness ?: 0f, contrast).takeUnless { it.isEmpty }
}
fun reset() {
colorFilter.value = null
}
fun save() {
launchLoadingJob {
mangaDataRepository.saveColorFilter(manga, colorFilter.value)
onDismiss.call(Unit)
}
}
@AssistedFactory
interface Factory {
fun create(manga: Manga, page: MangaPage): ColorFilterConfigViewModel
}
}

View File

@@ -0,0 +1,18 @@
package org.koitharu.kotatsu.reader.ui.colorfilter
import android.graphics.drawable.Drawable
import android.widget.ImageView
import coil.target.ImageViewTarget
class ShadowViewTarget(
view: ImageView,
private val shadowView: ImageView,
) : ImageViewTarget(view) {
override var drawable: Drawable?
get() = super.drawable
set(value) {
super.drawable = value
shadowView.setImageDrawable(value?.constantState?.newDrawable())
}
}

View File

@@ -20,6 +20,7 @@ import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding
import org.koitharu.kotatsu.reader.ui.PageSaveContract
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.utils.ScreenOrientationHelper
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
@@ -59,6 +60,7 @@ class ReaderConfigBottomSheet :
binding.buttonSavePage.setOnClickListener(this)
binding.buttonScreenRotate.setOnClickListener(this)
binding.buttonSettings.setOnClickListener(this)
binding.buttonColorFilter.setOnClickListener(this)
binding.sliderTimer.addOnSliderTouchListener(this)
binding.sliderTimer.setLabelFormatter(PageSwitchTimer.DelayLabelFormatter(view.resources))
@@ -80,6 +82,11 @@ class ReaderConfigBottomSheet :
R.id.button_screen_rotate -> {
orientationHelper?.toggleOrientation()
}
R.id.button_color_filter -> {
val page = viewModel.getCurrentPage() ?: return
val manga = viewModel.manga ?: return
startActivity(ColorFilterConfigActivity.newIntent(v.context, manga, page))
}
}
}

View File

@@ -0,0 +1,68 @@
package org.koitharu.kotatsu.reader.ui.config
import android.content.SharedPreferences
import androidx.lifecycle.MediatorLiveData
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
class ReaderSettings(
private val parentScope: CoroutineScope,
private val settings: AppSettings,
private val colorFilterFlow: StateFlow<ReaderColorFilter?>,
) : MediatorLiveData<ReaderSettings>() {
private val internalObserver = InternalObserver()
private var collectJob: Job? = null
val zoomMode: ZoomMode
get() = settings.zoomMode
val colorFilter: ReaderColorFilter?
get() = colorFilterFlow.value
val isPagesNumbersEnabled: Boolean
get() = settings.isPagesNumbersEnabled
override fun onInactive() {
super.onInactive()
settings.unsubscribe(internalObserver)
collectJob?.cancel()
collectJob = null
}
override fun onActive() {
super.onActive()
settings.subscribe(internalObserver)
collectJob?.cancel()
collectJob = parentScope.launch {
colorFilterFlow.collect(internalObserver)
}
}
override fun getValue() = this
private fun notifyChanged() {
value = value
}
private inner class InternalObserver :
FlowCollector<ReaderColorFilter?>,
SharedPreferences.OnSharedPreferenceChangeListener {
override suspend fun emit(value: ReaderColorFilter?) {
withContext(Dispatchers.Main.immediate) {
notifyChanged()
}
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (key == AppSettings.KEY_ZOOM_MODE || key == AppSettings.KEY_PAGES_NUMBERS) {
notifyChanged()
}
}
}
}

View File

@@ -5,15 +5,15 @@ import androidx.annotation.CallSuper
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
abstract class BasePageHolder<B : ViewBinding>(
protected val binding: B,
loader: PageLoader,
settings: AppSettings,
exceptionResolver: ExceptionResolver
settings: ReaderSettings,
exceptionResolver: ExceptionResolver,
) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback {
@Suppress("LeakingThis")
@@ -37,8 +37,16 @@ abstract class BasePageHolder<B : ViewBinding>(
protected abstract fun onBind(data: ReaderPage)
fun onAttachedToWindow() {
delegate.onAttachedToWindow()
}
fun onDetachedFromWindow() {
delegate.onDetachedFromWindow()
}
@CallSuper
open fun onRecycled() {
delegate.onRecycle()
}
}
}

View File

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

View File

@@ -4,17 +4,17 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.utils.ext.resetTransformations
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.utils.ext.resetTransformations
@Suppress("LeakingThis")
abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
private val loader: PageLoader,
private val settings: AppSettings,
private val readerSettings: ReaderSettings,
private val exceptionResolver: ExceptionResolver,
) : RecyclerView.Adapter<H>() {
@@ -35,6 +35,16 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
super.onViewRecycled(holder)
}
override fun onViewAttachedToWindow(holder: H) {
super.onViewAttachedToWindow(holder)
holder.onAttachedToWindow()
}
override fun onViewDetachedFromWindow(holder: H) {
holder.onDetachedFromWindow()
super.onViewDetachedFromWindow(holder)
}
open fun getItem(position: Int): ReaderPage = differ.currentList[position]
open fun getItemOrNull(position: Int) = differ.currentList.getOrNull(position)
@@ -45,8 +55,8 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
final override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): H = onCreateViewHolder(parent, loader, settings, exceptionResolver)
viewType: Int,
): H = onCreateViewHolder(parent, loader, readerSettings, exceptionResolver)
suspend fun setItems(items: List<ReaderPage>) = suspendCoroutine<Unit> { cont ->
differ.submitList(items) {
@@ -57,8 +67,8 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
protected abstract fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: AppSettings,
exceptionResolver: ExceptionResolver
settings: ReaderSettings,
exceptionResolver: ExceptionResolver,
): H
private class DiffCallback : DiffUtil.ItemCallback<ReaderPage>() {
@@ -70,6 +80,5 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
override fun areContentsTheSame(oldItem: ReaderPage, newItem: ReaderPage): Boolean {
return oldItem == newItem
}
}
}
}

View File

@@ -2,26 +2,26 @@ package org.koitharu.kotatsu.reader.ui.pager
import android.net.Uri
import androidx.core.net.toUri
import androidx.lifecycle.Observer
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import java.io.File
import java.io.IOException
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
import java.io.File
import java.io.IOException
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
class PageHolderDelegate(
private val loader: PageLoader,
private val settings: AppSettings,
private val readerSettings: ReaderSettings,
private val callback: Callback,
private val exceptionResolver: ExceptionResolver
) : SubsamplingScaleImageView.DefaultOnImageEventListener() {
private val exceptionResolver: ExceptionResolver,
) : SubsamplingScaleImageView.DefaultOnImageEventListener(), Observer<ReaderSettings> {
private val scope = loader.loaderScope + Dispatchers.Main.immediate
private var state = State.EMPTY
@@ -49,6 +49,14 @@ class PageHolderDelegate(
}
}
fun onAttachedToWindow() {
readerSettings.observeForever(this)
}
fun onDetachedFromWindow() {
readerSettings.removeObserver(this)
}
fun onRecycle() {
state = State.EMPTY
file = null
@@ -59,7 +67,7 @@ class PageHolderDelegate(
override fun onReady() {
state = State.SHOWING
error = null
callback.onImageShowing(settings.zoomMode)
callback.onImageShowing(readerSettings)
}
override fun onImageLoaded() {
@@ -79,6 +87,12 @@ class PageHolderDelegate(
}
}
override fun onChanged(t: ReaderSettings?) {
if (state == State.SHOWN) {
callback.onImageShowing(readerSettings)
}
}
private fun tryConvert(file: File, e: Exception) {
val prevJob = job
job = scope.launch {
@@ -134,10 +148,10 @@ class PageHolderDelegate(
fun onImageReady(uri: Uri)
fun onImageShowing(zoom: ZoomMode)
fun onImageShowing(settings: ReaderSettings)
fun onImageShown()
fun onProgressChanged(progress: Int)
}
}
}

View File

@@ -6,16 +6,16 @@ import android.widget.FrameLayout
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.standard.PageHolder
class ReversedPageHolder(
binding: ItemPageBinding,
loader: PageLoader,
settings: AppSettings,
exceptionResolver: ExceptionResolver
settings: ReaderSettings,
exceptionResolver: ExceptionResolver,
) : PageHolder(binding, loader, settings, exceptionResolver) {
init {
@@ -23,13 +23,14 @@ class ReversedPageHolder(
.gravity = Gravity.START or Gravity.BOTTOM
}
override fun onImageShowing(zoom: ZoomMode) {
override fun onImageShowing(settings: ReaderSettings) {
with(binding.ssiv) {
maxScale = 2f * maxOf(
width / sWidth.toFloat(),
height / sHeight.toFloat()
height / sHeight.toFloat(),
)
when (zoom) {
binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter()
when (settings.zoomMode) {
ZoomMode.FIT_CENTER -> {
setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE)
resetScaleAndCenter()
@@ -39,7 +40,7 @@ class ReversedPageHolder(
minScale = height / sHeight.toFloat()
setScaleAndCenter(
minScale,
PointF(sWidth.toFloat(), sHeight / 2f)
PointF(sWidth.toFloat(), sHeight / 2f),
)
}
ZoomMode.FIT_WIDTH -> {
@@ -47,17 +48,17 @@ class ReversedPageHolder(
minScale = width / sWidth.toFloat()
setScaleAndCenter(
minScale,
PointF(sWidth / 2f, 0f)
PointF(sWidth / 2f, 0f),
)
}
ZoomMode.KEEP_START -> {
setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE)
setScaleAndCenter(
maxScale,
PointF(sWidth.toFloat(), 0f)
PointF(sWidth.toFloat(), 0f),
)
}
}
}
}
}
}

View File

@@ -3,26 +3,26 @@ package org.koitharu.kotatsu.reader.ui.pager.reversed
import android.view.LayoutInflater
import android.view.ViewGroup
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class ReversedPagesAdapter(
loader: PageLoader,
settings: AppSettings,
exceptionResolver: ExceptionResolver
settings: ReaderSettings,
exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<ReversedPageHolder>(loader, settings, exceptionResolver) {
override fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: AppSettings,
exceptionResolver: ExceptionResolver
settings: ReaderSettings,
exceptionResolver: ExceptionResolver,
) = ReversedPageHolder(
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader,
settings = settings,
exceptionResolver = exceptionResolver
exceptionResolver = exceptionResolver,
)
}
}

View File

@@ -7,10 +7,8 @@ import android.view.View
import android.view.ViewGroup
import androidx.core.view.children
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlin.math.absoluteValue
import kotlinx.coroutines.async
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReader
@@ -25,9 +23,6 @@ import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
@AndroidEntryPoint
class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
@Inject
lateinit var settings: AppSettings
private var pagerAdapter: ReversedPagesAdapter? = null
override fun onInflateView(
@@ -38,7 +33,7 @@ class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
@SuppressLint("NotifyDataSetChanged")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
pagerAdapter = ReversedPagesAdapter(viewModel.pageLoader, settings, exceptionResolver)
pagerAdapter = ReversedPagesAdapter(viewModel.pageLoader, viewModel.readerSettings, exceptionResolver)
with(binding.pager) {
adapter = pagerAdapter
offscreenPageLimit = 2
@@ -54,9 +49,6 @@ class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
}
}
}
viewModel.onZoomChanged.observe(viewLifecycleOwner) {
pagerAdapter?.notifyDataSetChanged()
}
}
override fun onDestroyView() {

View File

@@ -12,9 +12,9 @@ import kotlinx.coroutines.asExecutor
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.*
@@ -22,7 +22,7 @@ import org.koitharu.kotatsu.utils.ext.*
open class PageHolder(
binding: ItemPageBinding,
loader: PageLoader,
settings: AppSettings,
settings: ReaderSettings,
exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageBinding>(binding, loader, settings, exceptionResolver),
View.OnClickListener {
@@ -66,12 +66,13 @@ open class PageHolder(
binding.ssiv.setImage(ImageSource.uri(uri))
}
override fun onImageShowing(zoom: ZoomMode) {
override fun onImageShowing(settings: ReaderSettings) {
binding.ssiv.maxScale = 2f * maxOf(
binding.ssiv.width / binding.ssiv.sWidth.toFloat(),
binding.ssiv.height / binding.ssiv.sHeight.toFloat()
binding.ssiv.height / binding.ssiv.sHeight.toFloat(),
)
when (zoom) {
binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter()
when (settings.zoomMode) {
ZoomMode.FIT_CENTER -> {
binding.ssiv.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE)
binding.ssiv.resetScaleAndCenter()
@@ -81,7 +82,7 @@ open class PageHolder(
binding.ssiv.minScale = binding.ssiv.height / binding.ssiv.sHeight.toFloat()
binding.ssiv.setScaleAndCenter(
binding.ssiv.minScale,
PointF(0f, binding.ssiv.sHeight / 2f)
PointF(0f, binding.ssiv.sHeight / 2f),
)
}
ZoomMode.FIT_WIDTH -> {
@@ -89,14 +90,14 @@ open class PageHolder(
binding.ssiv.minScale = binding.ssiv.width / binding.ssiv.sWidth.toFloat()
binding.ssiv.setScaleAndCenter(
binding.ssiv.minScale,
PointF(binding.ssiv.sWidth / 2f, 0f)
PointF(binding.ssiv.sWidth / 2f, 0f),
)
}
ZoomMode.KEEP_START -> {
binding.ssiv.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE)
binding.ssiv.setScaleAndCenter(
binding.ssiv.maxScale,
PointF(0f, 0f)
PointF(0f, 0f),
)
}
}
@@ -115,9 +116,9 @@ open class PageHolder(
override fun onError(e: Throwable) {
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
bindingInfo.buttonRetry.setText(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again }
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
)
bindingInfo.layoutError.isVisible = true
bindingInfo.progressBar.hideCompat()
}
}
}

View File

@@ -7,10 +7,8 @@ import android.view.View
import android.view.ViewGroup
import androidx.core.view.children
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlin.math.absoluteValue
import kotlinx.coroutines.async
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReader
@@ -24,9 +22,6 @@ import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
@AndroidEntryPoint
class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
@Inject
lateinit var settings: AppSettings
private var pagesAdapter: PagesAdapter? = null
override fun onInflateView(
@@ -37,7 +32,7 @@ class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
@SuppressLint("NotifyDataSetChanged")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
pagesAdapter = PagesAdapter(viewModel.pageLoader, settings, exceptionResolver)
pagesAdapter = PagesAdapter(viewModel.pageLoader, viewModel.readerSettings, exceptionResolver)
with(binding.pager) {
adapter = pagesAdapter
offscreenPageLimit = 2
@@ -53,9 +48,6 @@ class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
}
}
}
viewModel.onZoomChanged.observe(viewLifecycleOwner) {
pagesAdapter?.notifyDataSetChanged()
}
}
override fun onDestroyView() {

View File

@@ -3,26 +3,26 @@ package org.koitharu.kotatsu.reader.ui.pager.standard
import android.view.LayoutInflater
import android.view.ViewGroup
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class PagesAdapter(
loader: PageLoader,
settings: AppSettings,
exceptionResolver: ExceptionResolver
settings: ReaderSettings,
exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<PageHolder>(loader, settings, exceptionResolver) {
override fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: AppSettings,
exceptionResolver: ExceptionResolver
settings: ReaderSettings,
exceptionResolver: ExceptionResolver,
) = PageHolder(
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader,
settings = settings,
exceptionResolver = exceptionResolver
exceptionResolver = exceptionResolver,
)
}
}

View File

@@ -6,27 +6,28 @@ import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class WebtoonAdapter(
loader: PageLoader,
settings: AppSettings,
exceptionResolver: ExceptionResolver
settings: ReaderSettings,
exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<WebtoonHolder>(loader, settings, exceptionResolver) {
override fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: AppSettings,
exceptionResolver: ExceptionResolver
settings: ReaderSettings,
exceptionResolver: ExceptionResolver,
) = WebtoonHolder(
binding = ItemPageWebtoonBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
false,
),
loader = loader,
settings = settings,
exceptionResolver = exceptionResolver
exceptionResolver = exceptionResolver,
)
}
}

View File

@@ -7,10 +7,9 @@ import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.GoneOnInvisibleListener
@@ -19,7 +18,7 @@ import org.koitharu.kotatsu.utils.ext.*
class WebtoonHolder(
binding: ItemPageWebtoonBinding,
loader: PageLoader,
settings: AppSettings,
settings: ReaderSettings,
exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, exceptionResolver),
View.OnClickListener {
@@ -60,7 +59,8 @@ class WebtoonHolder(
binding.ssiv.setImage(ImageSource.uri(uri))
}
override fun onImageShowing(zoom: ZoomMode) {
override fun onImageShowing(settings: ReaderSettings) {
binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter()
with(binding.ssiv) {
setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CUSTOM)
minScale = width / sWidth.toFloat()
@@ -70,7 +70,7 @@ class WebtoonHolder(
scrollToRestore != 0 -> scrollToRestore
itemView.top < 0 -> getScrollRange()
else -> 0
}
},
)
scrollToRestore = 0
}
@@ -89,7 +89,7 @@ class WebtoonHolder(
override fun onError(e: Throwable) {
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
bindingInfo.buttonRetry.setText(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again }
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
)
bindingInfo.layoutError.isVisible = true
bindingInfo.progressBar.hideCompat()
@@ -104,4 +104,4 @@ class WebtoonHolder(
scrollToRestore = scroll
}
}
}
}

View File

@@ -6,9 +6,7 @@ import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.async
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReader
@@ -21,9 +19,6 @@ import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
@AndroidEntryPoint
class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
@Inject
lateinit var settings: AppSettings
private val scrollInterpolator = AccelerateDecelerateInterpolator()
private var webtoonAdapter: WebtoonAdapter? = null
@@ -34,7 +29,7 @@ class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
webtoonAdapter = WebtoonAdapter(viewModel.pageLoader, settings, exceptionResolver)
webtoonAdapter = WebtoonAdapter(viewModel.pageLoader, viewModel.readerSettings, exceptionResolver)
with(binding.recyclerView) {
setHasFixedSize(true)
adapter = webtoonAdapter

View File

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

View File

@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.databinding.ItemPageThumbBinding
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
import org.koitharu.kotatsu.utils.ext.decodeRegion
import org.koitharu.kotatsu.utils.ext.isLowRamDevice
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.setTextColorAttr
@@ -30,7 +31,7 @@ fun pageThumbnailAD(
val gridWidth = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width)
val thumbSize = Size(
width = gridWidth,
height = (gridWidth * 13f / 18f).toInt(),
height = (gridWidth / 13f * 18f).toInt(),
)
suspend fun loadPageThumbnail(item: PageThumbnail): Drawable? = withContext(Dispatchers.Default) {
@@ -52,6 +53,7 @@ fun pageThumbnailAD(
ImageRequest.Builder(context)
.data(file)
.size(thumbSize)
.decodeRegion()
.allowRgb565(isLowRamDevice(context))
.build(),
).drawable

View File

@@ -99,7 +99,7 @@ class RemoteListFragment : MangaListFragment() {
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)
return true
}

View File

@@ -10,10 +10,13 @@ private const val USER_AGENT_SHIKIMORI = "Kotatsu"
class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor {
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)
storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
if (!sourceRequest.url.pathSegments.contains("oauth")) {
storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
}
}
val response = chain.proceed(request.build())
if (!response.isSuccessful && !response.isRedirect) {
@@ -21,4 +24,4 @@ class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor
}
return response
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,34 +2,41 @@ package org.koitharu.kotatsu.settings.about
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.core.net.toUri
import androidx.fragment.app.viewModels
import androidx.preference.Preference
import kotlinx.coroutines.launch
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
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.settings.AppUpdateChecker
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
private val viewModel by viewModels<AboutSettingsViewModel>()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
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 {
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 {
return when (preference.key) {
AppSettings.KEY_APP_VERSION -> {
checkForUpdates()
viewModel.checkForUpdates()
true
}
AppSettings.KEY_APP_TRANSLATION -> {
@@ -40,24 +47,12 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
}
}
private fun checkForUpdates() {
viewLifecycleScope.launch {
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
setSummary(R.string.checking_for_updates)
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
}
private fun onUpdateAvailable(version: AppVersion?) {
if (version == null) {
Snackbar.make(listView, R.string.no_update_available, Snackbar.LENGTH_SHORT).show()
return
}
AppUpdateDialog(context ?: return).show(version)
}
private fun openLink(url: String, title: CharSequence?) {
@@ -68,7 +63,7 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
Intent.createChooser(intent, title)
} else {
intent
}
},
)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.databinding.FragmentToolsBinding
import org.koitharu.kotatsu.download.ui.DownloadsActivity
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.utils.FileSize
import org.koitharu.kotatsu.utils.ext.getThemeColor
@@ -68,6 +69,10 @@ class ToolsFragment :
intent.data = url.toUri()
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)
}
}
}

View File

@@ -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.toUi
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
@HiltViewModel
@@ -44,8 +44,8 @@ class SuggestionsViewModel @Inject constructor(
}.onFirst {
loadingCounter.decrement()
}.catch {
it.toErrorState(canRetry = false)
}.asLiveDataDistinct(
emit(listOf(it.toErrorState(canRetry = false)))
}.asFlowLiveData(
viewModelScope.coroutineContext + Dispatchers.Default,
listOf(LoadingState),
)

View File

@@ -5,9 +5,26 @@ import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
fun TrackLogWithManga.toTrackingLogItem() = TrackingLogItem(
id = trackLog.id,
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
manga = manga.toManga(tags.toMangaTags()),
createdAt = Date(trackLog.createdAt)
)
fun TrackLogWithManga.toTrackingLogItem(counters: MutableMap<Long, Int>): TrackingLogItem {
val chaptersList = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() }
return TrackingLogItem(
id = trackLog.id,
chapters = chaptersList,
manga = manga.toManga(tags.toMangaTags()),
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
}

View File

@@ -18,6 +18,10 @@ abstract class TracksDao {
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
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")
abstract fun observeNewChapters(): Flow<List<Int>>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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