Compare commits

...

21 Commits
v6.0.2 ... v6.1

Author SHA1 Message Date
Koitharu
81de6124f0 Rethrow CancellationException from TrackWorkers #489 2023-09-09 17:42:47 +03:00
Koitharu
a93bc0ed5b Increase max autoscroll speed 2023-09-08 13:48:34 +03:00
Koitharu
a1b96ebbb5 Update parsers 2023-09-08 13:30:26 +03:00
Koitharu
6b93e49f56 Improve loading both local and remote manga 2023-09-08 13:07:26 +03:00
Koitharu
c88a9dff36 Handle enter press in search view 2023-09-08 09:17:04 +03:00
Koitharu
ca47c475d3 Avoid passing manga chapters via extras 2023-09-07 18:15:48 +03:00
Koitharu
8df7fa2729 Fix crashes 2023-09-07 16:40:07 +03:00
Koitharu
ea34abb1d7 Fix categories reordering 2023-09-07 14:06:52 +03:00
Koitharu
c4ff37350c Option to move manga source to top 2023-09-07 13:27:13 +03:00
Koitharu
95547a8d03 Configurable main navigation 2023-09-06 14:42:00 +03:00
Koitharu
4c2197aa5d Option to retry captcha resolving 2023-09-05 11:26:57 +03:00
Koitharu
a679b6775d Exclude captcha actvity from recent 2023-09-05 10:35:32 +03:00
Koitharu
d3e4e97c6f Fix tracker operations parallelism 2023-09-05 10:30:20 +03:00
Koitharu
d1b0af85c4 Update parsers 2023-09-05 10:30:20 +03:00
Koitharu
ce95e0657b Translated using Weblate (Russian)
Currently translated at 100.0% (476 of 476 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-09-05 10:30:08 +03:00
Nayuki
6bb159a6d9 Translated using Weblate (Thai)
Currently translated at 57.5% (274 of 476 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2023-09-05 10:30:08 +03:00
Макар Разин
a75583f750 Translated using Weblate (Belarusian)
Currently translated at 100.0% (476 of 476 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
2023-09-05 10:30:08 +03:00
Koitharu
fff9df9609 Fix categories and sources reordering 2023-09-03 17:39:52 +03:00
Koitharu
f9609edea5 Fallback to old systemUiVisibility in reader 2023-09-03 17:06:37 +03:00
Koitharu
f1245742c0 Merge branch 'File_creation_time' of github.com:Isira-Seneviratne/Kotatsu into Isira-Seneviratne-File_creation_time 2023-09-01 13:43:58 +03:00
Isira Seneviratne
ded7cdb71e Obtain file creation time 2023-08-27 06:19:07 +05:30
103 changed files with 1749 additions and 758 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 34
versionCode = 575
versionName = '6.0.2'
versionCode = 577
versionName = '6.1'
generatedDensities = []
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
ksp {
@@ -81,7 +81,7 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:3a76504380') {
implementation('com.github.KotatsuApp:kotatsu-parsers:aae3fa3b05') {
exclude group: 'org.json', module: 'json'
}

View File

@@ -139,6 +139,7 @@
<activity
android:name="org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:autoRemoveFromRecents="true"
android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity"

View File

@@ -61,11 +61,17 @@ class BookmarksFragment :
private var bookmarksAdapter: BookmarksAdapter? = null
private var selectionController: ListSelectionController? = null
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentListSimpleBinding {
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
): FragmentListSimpleBinding {
return FragmentListSimpleBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: FragmentListSimpleBinding, savedInstanceState: Bundle?) {
override fun onViewBindingCreated(
binding: FragmentListSimpleBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState)
selectionController = ListSelectionController(
activity = requireActivity(),
@@ -95,7 +101,10 @@ class BookmarksFragment :
viewModel.content.observe(viewLifecycleOwner) {
bookmarksAdapter?.setItems(it, spanSizeLookup)
}
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onError.observeEvent(
viewLifecycleOwner,
SnackbarErrorObserver(binding.recyclerView, this)
)
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ::onActionDone)
}
@@ -139,12 +148,20 @@ class BookmarksFragment :
requireViewBinding().recyclerView.invalidateItemDecorations()
}
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
override fun onCreateActionMode(
controller: ListSelectionController,
mode: ActionMode,
menu: Menu,
): Boolean {
mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
return true
}
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
override fun onActionItemClicked(
controller: ListSelectionController,
mode: ActionMode,
item: MenuItem,
): Boolean {
return when (item.itemId) {
R.id.action_remove -> {
val ids = selectionController?.snapshot() ?: return false
@@ -170,7 +187,8 @@ class BookmarksFragment :
private fun onActionDone(action: ReversibleAction) {
val handle = action.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
val snackbar = Snackbar.make((activity as SnackbarOwner).snackbarHost, action.stringResId, length)
val snackbar =
Snackbar.make((activity as SnackbarOwner).snackbarHost, action.stringResId, length)
if (handle != null) {
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
}
@@ -185,7 +203,8 @@ class BookmarksFragment :
}
override fun getSpanSize(position: Int): Int {
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount
?: return 1
return when (bookmarksAdapter?.getItemViewType(position)) {
ListItemType.PAGE_THUMB.ordinal -> 1
else -> total
@@ -200,6 +219,12 @@ class BookmarksFragment :
companion object {
@Deprecated(
"", ReplaceWith(
"BookmarksFragment()",
"org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment"
)
)
fun newInstance() = BookmarksFragment()
}
}

View File

@@ -162,7 +162,7 @@ class BookmarksSheet :
fun show(fm: FragmentManager, manga: Manga) {
BookmarksSheet().withArgs(1) {
putParcelable(ARG_MANGA, ParcelableManga(manga, withChapters = true))
putParcelable(ARG_MANGA, ParcelableManga(manga))
}.showDistinct(fm, TAG)
}
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.browser.cloudflare
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.webkit.CookieManager
import androidx.activity.result.contract.ActivityResultContract
@@ -11,8 +12,14 @@ import androidx.core.net.toUri
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.yield
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
@@ -38,7 +45,13 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
if (!catchingWebViewUnavailability {
setContentView(
ActivityBrowserBinding.inflate(
layoutInflater
)
)
}) {
return
}
supportActionBar?.run {
@@ -86,6 +99,11 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
viewBinding.webView.restoreState(savedInstanceState)
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.opt_captcha, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.appbar.updatePadding(
top = insets.top,
@@ -104,6 +122,19 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
true
}
R.id.action_retry -> {
lifecycleScope.launch {
viewBinding.webView.stopLoading()
yield()
val targetUrl = intent?.dataString?.toHttpUrlOrNull()
if (targetUrl != null) {
clearCfCookies(targetUrl)
viewBinding.webView.loadUrl(targetUrl.toString())
}
}
true
}
else -> super.onOptionsItemSelected(item)
}
@@ -141,7 +172,15 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
setTitle(title)
supportActionBar?.subtitle = subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
supportActionBar?.subtitle =
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
}
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
cookieJar.removeCookies(url) { cookie ->
val name = cookie.name
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf")
}
}
class Contract : ActivityResultContract<Pair<String, Headers?>, TaggedActivityResult>() {

View File

@@ -30,7 +30,8 @@ import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.StubContentCache
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.network.*
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
@@ -40,7 +41,6 @@ import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.util.AcraScreenLogger
import org.koitharu.kotatsu.core.util.IncognitoModeIndicator
import org.koitharu.kotatsu.core.util.ext.activityManager
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.local.data.CacheDir
@@ -161,7 +161,7 @@ interface AppModule {
fun provideContentCache(
application: Application,
): ContentCache {
return if (application.activityManager?.isLowRamDevice == true) {
return if (application.isLowRamDevice()) {
StubContentCache()
} else {
MemoryContentCache(application)

View File

@@ -6,6 +6,7 @@ import androidx.room.InvalidationTracker
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -118,7 +119,7 @@ fun MangaDatabase(context: Context): MangaDatabase = Room
fun InvalidationTracker.removeObserverAsync(observer: InvalidationTracker.Observer) {
val scope = processLifecycleScope
if (scope.isActive) {
processLifecycleScope.launch(Dispatchers.Default) {
processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) {
removeObserver(observer)
}
}

View File

@@ -6,3 +6,4 @@ const val TABLE_TAGS = "tags"
const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories"
const val TABLE_HISTORY = "history"
const val TABLE_MANGA_TAGS = "manga_tags"
const val TABLE_SOURCES = "sources"

View File

@@ -3,9 +3,10 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.TABLE_SOURCES
@Entity(
tableName = "sources",
tableName = TABLE_SOURCES,
)
data class MangaSourceEntity(
@PrimaryKey(autoGenerate = false)

View File

@@ -17,6 +17,8 @@ fun Collection<Manga>.distinctById() = distinctBy { it.id }
@JvmName("chaptersIds")
fun Collection<MangaChapter>.ids() = mapToSet { it.id }
fun Collection<MangaChapter>.findById(id: Long) = find { x -> x.id == id }
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) {
return size
@@ -30,7 +32,7 @@ fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
}
fun Manga.findChapter(id: Long): MangaChapter? {
return chapters?.find { it.id == id }
return chapters?.findById(id)
}
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
@@ -39,7 +41,7 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
return null
}
if (history != null) {
val currentChapter = ch.find { it.id == history.chapterId }
val currentChapter = ch.findById(history.chapterId)
if (currentChapter != null) {
return currentChapter.branch
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.model
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale
@@ -15,3 +16,5 @@ fun MangaSource(name: String): MangaSource {
}
return MangaSource.DUMMY
}
fun MangaSource.isNsfw() = contentType == ContentType.HENTAI

View File

@@ -0,0 +1,42 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
@Parcelize
data class ParcelableChapter(
val chapter: MangaChapter,
) : Parcelable {
companion object : Parceler<ParcelableChapter> {
override fun create(parcel: Parcel) = ParcelableChapter(
MangaChapter(
id = parcel.readLong(),
name = parcel.readString().orEmpty(),
number = parcel.readInt(),
url = parcel.readString().orEmpty(),
scanlator = parcel.readString(),
uploadDate = parcel.readLong(),
branch = parcel.readString(),
source = parcel.readSerializableCompat() ?: MangaSource.DUMMY,
)
)
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
parcel.writeLong(id)
parcel.writeString(name)
parcel.writeInt(number)
parcel.writeString(url)
parcel.writeString(scanlator)
parcel.writeLong(uploadDate)
parcel.writeString(branch)
parcel.writeSerializable(source)
}
}
}

View File

@@ -9,55 +9,28 @@ import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.Manga
// Limits to avoid TransactionTooLargeException
private const val MAX_SAFE_SIZE = 1024 * 100 // Assume that 100 kb is safe parcel size
private const val MAX_SAFE_CHAPTERS_COUNT = 24 // this is 100% safe
@Parcelize
data class ParcelableManga(
val manga: Manga,
private val withChapters: Boolean,
) : Parcelable {
companion object : Parceler<ParcelableManga> {
private fun Manga.writeToParcel(out: Parcel, flags: Int, withChapters: Boolean) {
out.writeLong(id)
out.writeString(title)
out.writeString(altTitle)
out.writeString(url)
out.writeString(publicUrl)
out.writeFloat(rating)
ParcelCompat.writeBoolean(out, isNsfw)
out.writeString(coverUrl)
out.writeString(largeCoverUrl)
out.writeString(description)
out.writeParcelable(ParcelableMangaTags(tags), flags)
out.writeSerializable(state)
out.writeString(author)
val parcelableChapters = if (withChapters) null else chapters?.let(::ParcelableMangaChapters)
out.writeParcelable(parcelableChapters, flags)
out.writeSerializable(source)
}
override fun ParcelableManga.write(parcel: Parcel, flags: Int) {
val chapters = manga.chapters
if (!withChapters || chapters == null) {
manga.writeToParcel(parcel, flags, withChapters = false)
return
}
if (chapters.size <= MAX_SAFE_CHAPTERS_COUNT) {
// fast path
manga.writeToParcel(parcel, flags, withChapters = true)
return
}
val tempParcel = Parcel.obtain()
manga.writeToParcel(tempParcel, flags, withChapters = true)
val size = tempParcel.dataSize()
if (size < MAX_SAFE_SIZE) {
parcel.appendFrom(tempParcel, 0, size)
} else {
manga.writeToParcel(parcel, flags, withChapters = false)
}
tempParcel.recycle()
companion object : Parceler<ParcelableManga> {
override fun ParcelableManga.write(parcel: Parcel, flags: Int) = with(manga) {
parcel.writeLong(id)
parcel.writeString(title)
parcel.writeString(altTitle)
parcel.writeString(url)
parcel.writeString(publicUrl)
parcel.writeFloat(rating)
ParcelCompat.writeBoolean(parcel, isNsfw)
parcel.writeString(coverUrl)
parcel.writeString(largeCoverUrl)
parcel.writeString(description)
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
parcel.writeSerializable(state)
parcel.writeString(author)
parcel.writeSerializable(source)
}
override fun create(parcel: Parcel) = ParcelableManga(
@@ -75,10 +48,9 @@ data class ParcelableManga(
tags = requireNotNull(parcel.readParcelableCompat<ParcelableMangaTags>()).tags,
state = parcel.readSerializableCompat(),
author = parcel.readString(),
chapters = parcel.readParcelableCompat<ParcelableMangaChapters>()?.chapters,
chapters = null,
source = requireNotNull(parcel.readSerializableCompat()),
),
withChapters = true
)
)
}
}

View File

@@ -1,37 +0,0 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.MangaChapter
object MangaChapterParceler : Parceler<MangaChapter> {
override fun create(parcel: Parcel) = MangaChapter(
id = parcel.readLong(),
name = requireNotNull(parcel.readString()),
number = parcel.readInt(),
url = requireNotNull(parcel.readString()),
scanlator = parcel.readString(),
uploadDate = parcel.readLong(),
branch = parcel.readString(),
source = requireNotNull(parcel.readSerializableCompat()),
)
override fun MangaChapter.write(parcel: Parcel, flags: Int) {
parcel.writeLong(id)
parcel.writeString(name)
parcel.writeInt(number)
parcel.writeString(url)
parcel.writeString(scanlator)
parcel.writeLong(uploadDate)
parcel.writeString(branch)
parcel.writeSerializable(source)
}
}
@Parcelize
@TypeParceler<MangaChapter, MangaChapterParceler>
data class ParcelableMangaChapters(val chapters: List<MangaChapter>) : Parcelable

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.network.cookies
import android.webkit.CookieManager
import androidx.annotation.WorkerThread
import androidx.core.util.Predicate
import okhttp3.Cookie
import okhttp3.HttpUrl
import org.koitharu.kotatsu.core.util.ext.newBuilder
@@ -31,19 +32,21 @@ class AndroidCookieJar : MutableCookieJar {
}
}
override fun removeCookies(url: HttpUrl) {
override fun removeCookies(url: HttpUrl, predicate: Predicate<Cookie>?) {
val cookies = loadForRequest(url)
if (cookies.isEmpty()) {
return
}
val urlString = url.toString()
for (c in cookies) {
if (predicate != null && !predicate.test(c)) {
continue
}
val nc = c.newBuilder()
.expiresAt(System.currentTimeMillis() - 100000)
.build()
cookieManager.setCookie(urlString, nc.toString())
}
check(loadForRequest(url).isEmpty())
}
override suspend fun clear() = suspendCoroutine<Boolean> { continuation ->

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.network.cookies
import androidx.annotation.WorkerThread
import androidx.core.util.Predicate
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
@@ -14,7 +15,7 @@ interface MutableCookieJar : CookieJar {
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>)
@WorkerThread
fun removeCookies(url: HttpUrl)
fun removeCookies(url: HttpUrl, predicate: Predicate<Cookie>?)
suspend fun clear(): Boolean
}

View File

@@ -4,6 +4,7 @@ import android.content.Context
import androidx.annotation.WorkerThread
import androidx.collection.ArrayMap
import androidx.core.content.edit
import androidx.core.util.Predicate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Cookie
@@ -57,12 +58,14 @@ class PreferencesCookieJar(
@Synchronized
@WorkerThread
override fun removeCookies(url: HttpUrl) {
override fun removeCookies(url: HttpUrl, predicate: Predicate<Cookie>?) {
loadPersistent()
val toRemove = HashSet<String>()
for ((key, cookie) in cache) {
if (cookie.isExpired() || cookie.cookie.matches(url)) {
toRemove += key
if (predicate == null || predicate.test(cookie.cookie)) {
toRemove += key
}
}
}
if (toRemove.isNotEmpty()) {

View File

@@ -24,6 +24,7 @@ import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.history.domain.model.HistoryOrder
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet
import java.io.File
@@ -43,7 +44,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
val theme: Int
get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
get() = prefs.getString(KEY_THEME, null)?.toIntOrNull()
?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
val colorScheme: ColorScheme
get() = prefs.getEnumValue(KEY_COLOR_THEME, ColorScheme.default)
@@ -51,8 +53,20 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isAmoledTheme: Boolean
get() = prefs.getBoolean(KEY_THEME_AMOLED, false)
val isFavoritesNavItemFirst: Boolean
get() = (prefs.getString(KEY_FIRST_NAV_ITEM, null)?.toIntOrNull() ?: 0) == 1
var mainNavItems: List<NavItem>
get() {
val raw = prefs.getString(KEY_NAV_MAIN, null)?.split(',')
return if (raw.isNullOrEmpty()) {
listOf(NavItem.HISTORY, NavItem.FAVORITES, NavItem.EXPLORE, NavItem.FEED)
} else {
raw.mapNotNull { x -> NavItem.entries.find(x) }.ifEmpty { listOf(NavItem.EXPLORE) }
}
}
set(value) {
prefs.edit {
putString(KEY_NAV_MAIN, value.joinToString(",") { it.name })
}
}
var gridSize: Int
get() = prefs.getInt(KEY_GRID_SIZE, 100)
@@ -145,7 +159,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
var appPassword: String?
get() = prefs.getString(KEY_APP_PASSWORD, null)
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
set(value) = prefs.edit {
if (value != null) putString(KEY_APP_PASSWORD, value) else remove(
KEY_APP_PASSWORD
)
}
val isLoggingEnabled: Boolean
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
@@ -171,7 +189,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
if (isBackgroundNetworkRestricted()) {
return false
}
val policy = NetworkPolicy.from(prefs.getString(KEY_PREFETCH_CONTENT, null), NetworkPolicy.NEVER)
val policy =
NetworkPolicy.from(prefs.getString(KEY_PREFETCH_CONTENT, null), NetworkPolicy.NEVER)
return policy.isNetworkAllowed(connectivityManager)
}
@@ -292,14 +311,22 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
@get:FloatRange(from = 0.0, to = 1.0)
var readerAutoscrollSpeed: Float
get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f)
set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit { putFloat(KEY_READER_AUTOSCROLL_SPEED, value) }
set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit {
putFloat(
KEY_READER_AUTOSCROLL_SPEED,
value
)
}
val isPagesPreloadEnabled: Boolean
get() {
if (isBackgroundNetworkRestricted()) {
return false
}
val policy = NetworkPolicy.from(prefs.getString(KEY_PAGES_PRELOAD, null), NetworkPolicy.NON_METERED)
val policy = NetworkPolicy.from(
prefs.getString(KEY_PAGES_PRELOAD, null),
NetworkPolicy.NON_METERED
)
return policy.isNetworkAllowed(connectivityManager)
}
@@ -455,7 +482,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
const val KEY_DISABLE_NSFW = "no_nsfw"
const val KEY_RELATED_MANGA = "related_manga"
const val KEY_FIRST_NAV_ITEM = "nav_first"
const val KEY_NAV_MAIN = "nav_main"
// About
const val KEY_APP_UPDATE = "app_update"

View File

@@ -0,0 +1,33 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.DrawableRes
import androidx.annotation.IdRes
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.model.ListModel
enum class NavItem(
@IdRes val id: Int,
@StringRes val title: Int,
@DrawableRes val icon: Int,
) : ListModel {
HISTORY(R.id.nav_history, R.string.history, R.drawable.ic_history_selector),
FAVORITES(R.id.nav_favorites, R.string.favourites, R.drawable.ic_favourites_selector),
LOCAL(R.id.nav_local, R.string.on_device, R.drawable.ic_storage_selector),
EXPLORE(R.id.nav_explore, R.string.explore, R.drawable.ic_explore_selector),
SUGGESTIONS(R.id.nav_suggestions, R.string.suggestions, R.drawable.ic_suggestion_selector),
FEED(R.id.nav_feed, R.string.feed, R.drawable.ic_feed_selector),
BOOKMARKS(R.id.nav_bookmarks, R.string.bookmarks, R.drawable.ic_bookmark_selector),
;
override fun areItemsTheSame(other: ListModel): Boolean {
return other is NavItem && ordinal == other.ordinal
}
fun isAvailable(settings: AppSettings): Boolean = when (this) {
SUGGESTIONS -> settings.isSuggestionsEnabled
FEED -> settings.isTrackerEnabled
else -> true
}
}

View File

@@ -5,20 +5,19 @@ import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import androidx.core.content.ContextCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.util.SystemUiController
abstract class BaseFullscreenActivity<B : ViewBinding> :
BaseActivity<B>() {
private lateinit var insetsControllerCompat: WindowInsetsControllerCompat
protected lateinit var systemUiController: SystemUiController
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
with(window) {
insetsControllerCompat = WindowInsetsControllerCompat(this, decorView)
systemUiController = SystemUiController(this)
statusBarColor = Color.TRANSPARENT
navigationBarColor = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
ContextCompat.getColor(this@BaseFullscreenActivity, R.color.dim)
@@ -30,15 +29,7 @@ abstract class BaseFullscreenActivity<B : ViewBinding> :
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
insetsControllerCompat.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
showSystemUI()
}
protected fun hideSystemUI() {
insetsControllerCompat.hide(WindowInsetsCompat.Type.systemBars())
}
protected fun showSystemUI() {
insetsControllerCompat.show(WindowInsetsCompat.Type.systemBars())
// insetsControllerCompat.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
systemUiController.setSystemUiVisible(true)
}
}

View File

@@ -0,0 +1,60 @@
package org.koitharu.kotatsu.core.ui.util
import android.os.Build
import android.view.View
import android.view.Window
import android.view.WindowInsets
import android.view.WindowInsetsController
import androidx.annotation.RequiresApi
sealed class SystemUiController(
protected val window: Window,
) {
abstract fun setSystemUiVisible(value: Boolean)
@RequiresApi(Build.VERSION_CODES.S)
private class Api30Impl(window: Window) : SystemUiController(window) {
private val insetsController = checkNotNull(window.decorView.windowInsetsController)
override fun setSystemUiVisible(value: Boolean) {
if (value) {
insetsController.show(WindowInsets.Type.systemBars())
insetsController.systemBarsBehavior = WindowInsetsController.BEHAVIOR_DEFAULT
} else {
insetsController.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
insetsController.hide(WindowInsets.Type.systemBars())
}
}
}
@Suppress("DEPRECATION")
private class LegacyImpl(window: Window) : SystemUiController(window) {
override fun setSystemUiVisible(value: Boolean) {
window.decorView.systemUiVisibility = if (value) {
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
} else {
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
}
}
companion object {
operator fun invoke(window: Window): SystemUiController =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
Api30Impl(window)
} else {
LegacyImpl(window)
}
}
}

View File

@@ -5,6 +5,7 @@ import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.viewpager.widget.ViewPager
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
@SuppressLint("ClickableViewAccessibility")
class EnhancedViewPager @JvmOverloads constructor(
@@ -25,6 +26,11 @@ class EnhancedViewPager @JvmOverloads constructor(
}
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
return isUserInputEnabled && super.onInterceptTouchEvent(event)
return try {
isUserInputEnabled && super.onInterceptTouchEvent(event)
} catch (e: IllegalArgumentException) {
e.printStackTraceDebug()
false
}
}
}

View File

@@ -16,8 +16,10 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.fs.FileSequence
import java.io.File
import java.io.FileFilter
import java.nio.file.attribute.BasicFileAttributes
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import kotlin.io.path.readAttributes
fun File.subdir(name: String) = File(this, name).also {
if (!it.exists()) it.mkdirs()
@@ -99,3 +101,10 @@ private suspend fun SequenceScope<File>.listFilesRecursiveImpl(root: File, filte
fun File.children() = FileSequence(this)
fun Sequence<File>.filterWith(filter: FileFilter): Sequence<File> = filter { f -> filter.accept(f) }
val File.creationTime
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
toPath().readAttributes<BasicFileAttributes>().creationTime().toMillis()
} else {
lastModified()
}

View File

@@ -5,6 +5,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
@@ -72,7 +74,7 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
transform: suspend (T1, T2, T3, T4, T5, T6) -> R
transform: suspend (T1, T2, T3, T4, T5, T6) -> R,
): Flow<R> = combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> ->
transform(
args[0] as T1,
@@ -83,3 +85,7 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
args[5] as T6,
)
}
suspend fun <T : Any> Flow<T?>.firstNotNull(): T = checkNotNull(first { x -> x != null })
suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x != null }

View File

@@ -27,6 +27,7 @@ import java.net.SocketTimeoutException
import java.net.UnknownHostException
private const val MSG_NO_SPACE_LEFT = "No space left on device"
private const val IMAGE_FORMAT_NO_SUPPORTED = "Image format not supported"
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is AuthRequiredException -> resources.getString(R.string.auth_required)
@@ -81,6 +82,7 @@ private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String
private fun getDisplayMessage(msg: String?, resources: Resources): String? = when {
msg.isNullOrEmpty() -> null
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
msg.contains(IMAGE_FORMAT_NO_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
else -> null
}

View File

@@ -8,10 +8,11 @@ import android.view.ViewGroup
import android.view.ViewParent
import android.view.inputmethod.InputMethodManager
import android.widget.Checkable
import android.widget.CompoundButton
import androidx.core.view.children
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.progressindicator.BaseProgressIndicator
import com.google.android.material.slider.Slider
import com.google.android.material.tabs.TabLayout
import kotlin.math.roundToInt
@@ -155,3 +156,11 @@ fun TabLayout.setTabsEnabled(enabled: Boolean) {
getTabAt(i)?.view?.isEnabled = enabled
}
}
fun BaseProgressIndicator<*>.showOrHide(value: Boolean) {
if (value) {
if (!isVisible) show()
} else {
if (isVisible) hide()
}
}

View File

@@ -1,8 +1,11 @@
package org.koitharu.kotatsu.details.domain
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaIntent
@@ -23,24 +26,28 @@ class DoubleMangaLoadUseCase @Inject constructor(
private val recoverUseCase: RecoverMangaUseCase,
) {
suspend operator fun invoke(manga: Manga): DoubleManga = coroutineScope {
val remoteDeferred = async(Dispatchers.Default) { loadRemote(manga) }
val localDeferred = async(Dispatchers.Default) { loadLocal(manga) }
DoubleManga(
remoteManga = remoteDeferred.await(),
localManga = localDeferred.await(),
)
}
operator fun invoke(manga: Manga): Flow<DoubleManga> = flow<DoubleManga> {
var lastValue: DoubleManga? = null
var emitted = false
invokeImpl(manga).collect {
lastValue = it
if (it.any != null) {
emitted = true
emit(it)
}
}
if (!emitted) {
lastValue?.requireAny()
}
}.flowOn(Dispatchers.Default)
suspend operator fun invoke(mangaId: Long): DoubleManga {
val manga = mangaDataRepository.findMangaById(mangaId) ?: throwNFE()
return invoke(manga)
}
operator fun invoke(mangaId: Long): Flow<DoubleManga> = flow {
emit(mangaDataRepository.findMangaById(mangaId) ?: throwNFE())
}.flatMapLatest { invoke(it) }
suspend operator fun invoke(intent: MangaIntent): DoubleManga {
val manga = mangaDataRepository.resolveIntent(intent) ?: throwNFE()
return invoke(manga)
}
operator fun invoke(intent: MangaIntent): Flow<DoubleManga> = flow {
emit(mangaDataRepository.resolveIntent(intent) ?: throwNFE())
}.flatMapLatest { invoke(it) }
private suspend fun loadLocal(manga: Manga): Result<Manga>? {
return runCatchingCancellable {
@@ -70,5 +77,15 @@ class DoubleMangaLoadUseCase @Inject constructor(
}
}
private fun invokeImpl(manga: Manga): Flow<DoubleManga> = combine(
flow { emit(null); emit(loadRemote(manga)) },
flow { emit(null); emit(loadLocal(manga)) },
) { remote, local ->
DoubleManga(
remoteManga = remote,
localManga = local,
)
}
private fun throwNFE(): Nothing = throw NotFoundException("Cannot find manga", "")
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.details.domain.model
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -34,6 +35,10 @@ data class DoubleManga(
mergeChapters()
}
fun hasChapter(id: Long): Boolean {
return local?.chapters?.findById(id) != null || remote?.chapters?.findById(id) != null
}
fun requireAny(): Manga {
val result = remoteManga?.getOrNull() ?: localManga?.getOrNull()
if (result != null) {

View File

@@ -5,8 +5,9 @@ import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
@@ -34,12 +35,13 @@ class MangaPrefetchService : CoroutineIntentService() {
override suspend fun processIntent(startId: Int, intent: Intent) {
when (intent.action) {
ACTION_PREFETCH_DETAILS -> prefetchDetails(
manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga ?: return,
manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga
?: return,
)
ACTION_PREFETCH_PAGES -> prefetchPages(
chapter = intent.getParcelableExtraCompat<ParcelableMangaChapters>(EXTRA_CHAPTER)
?.chapters?.singleOrNull() ?: return,
chapter = intent.getParcelableExtraCompat<ParcelableChapter>(EXTRA_CHAPTER)?.chapter
?: return,
)
ACTION_PREFETCH_LAST -> prefetchLast()
@@ -71,7 +73,7 @@ class MangaPrefetchService : CoroutineIntentService() {
val chapter = if (history == null) {
chapters.firstOrNull()
} else {
chapters.find { x -> x.id == history.chapterId } ?: chapters.firstOrNull()
chapters.findById(history.chapterId) ?: chapters.firstOrNull()
} ?: return
runCatchingCancellable { repo.getPages(chapter) }
}
@@ -88,7 +90,7 @@ class MangaPrefetchService : CoroutineIntentService() {
if (!isPrefetchAvailable(context, manga.source)) return
val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_DETAILS
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga))
context.startService(intent)
}
@@ -96,7 +98,7 @@ class MangaPrefetchService : CoroutineIntentService() {
if (!isPrefetchAvailable(context, chapter.source)) return
val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_PAGES
intent.putExtra(EXTRA_CHAPTER, ParcelableMangaChapters(listOf(chapter)))
intent.putExtra(EXTRA_CHAPTER, ParcelableChapter(chapter))
try {
context.startService(intent)
} catch (e: IllegalStateException) {
@@ -119,7 +121,10 @@ class MangaPrefetchService : CoroutineIntentService() {
if (context.isPowerSaveMode()) {
return false
}
val entryPoint = EntryPointAccessors.fromApplication(context, PrefetchCompanionEntryPoint::class.java)
val entryPoint = EntryPointAccessors.fromApplication(
context,
PrefetchCompanionEntryPoint::class.java,
)
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled
}
}

View File

@@ -369,7 +369,7 @@ class DetailsActivity :
fun newIntent(context: Context, manga: Manga): Intent {
return Intent(context, DetailsActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
}
fun newIntent(context: Context, mangaId: Long): Intent {

View File

@@ -43,6 +43,7 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
@@ -247,11 +248,7 @@ class DetailsFragment :
}
private fun onLoadingStateChanged(isLoading: Boolean) {
if (isLoading) {
requireViewBinding().progressBar.show()
} else {
requireViewBinding().progressBar.hide()
}
requireViewBinding().progressBar.showOrHide(isLoading)
}
private fun onBookmarksChanged(bookmarks: List<Bookmark>) {

View File

@@ -42,6 +42,7 @@ import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.combine
import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.onFirst
import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
@@ -87,7 +88,8 @@ class DetailsViewModel @Inject constructor(
private val intent = MangaIntent(savedStateHandle)
private val mangaId = intent.mangaId
private val doubleManga: MutableStateFlow<DoubleManga?> = MutableStateFlow(intent.manga?.let { DoubleManga(it) })
private val doubleManga: MutableStateFlow<DoubleManga?> =
MutableStateFlow(intent.manga?.let { DoubleManga(it) })
private var loadingJob: Job
val onShowToast = MutableEventFlow<Int>()
@@ -202,7 +204,14 @@ class DetailsViewModel @Inject constructor(
bookmarks,
networkState,
) { manga, history, branch, news, bookmarks, isOnline ->
mapChapters(manga?.remote?.takeIf { isOnline }, manga?.local, history, news, branch, bookmarks)
mapChapters(
manga?.remote?.takeIf { isOnline },
manga?.local,
history,
news,
branch,
bookmarks,
)
},
isChaptersReversed,
chaptersQuery,
@@ -324,12 +333,15 @@ class DetailsViewModel @Inject constructor(
}
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
val result = doubleMangaLoadUseCase(intent)
val manga = result.requireAny()
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = manga.getPreferredBranch(hist)
doubleManga.value = result
doubleMangaLoadUseCase.invoke(intent)
.onFirst {
val manga = it.requireAny()
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = manga.getPreferredBranch(hist)
}.collect {
doubleManga.value = it
}
}
private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> {

View File

@@ -45,6 +45,6 @@ class RelatedMangaActivity : BaseActivity<ActivityContainerBinding>(), AppBarOwn
companion object {
fun newIntent(context: Context, seed: Manga) = Intent(context, RelatedMangaActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(seed, withChapters = false))
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(seed))
}
}

View File

@@ -67,7 +67,7 @@ class FavouriteCategoriesActivity :
attachToRecyclerView(viewBinding.recyclerView)
}
viewModel.categories.observe(this, ::onCategoriesChanged)
viewModel.content.observe(this, ::onCategoriesChanged)
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
}

View File

@@ -1,21 +1,23 @@
package org.koitharu.kotatsu.favourites.ui.categories
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.util.move
import javax.inject.Inject
@@ -26,28 +28,19 @@ class FavouritesCategoriesViewModel @Inject constructor(
private val settings: AppSettings,
) : BaseViewModel() {
private var reorderJob: Job? = null
private var commitJob: Job? = null
val categories = repository.observeCategoriesWithCovers()
.map { list ->
list.map { (category, covers) ->
CategoryListModel(
mangaCount = covers.size,
covers = covers.take(3),
category = category,
isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources,
)
}.ifEmpty {
listOf(
EmptyState(
icon = R.drawable.ic_empty_favourites,
textPrimary = R.string.text_empty_holder_primary,
textSecondary = R.string.empty_favourite_categories,
actionStringRes = 0,
),
)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
init {
launchJob(Dispatchers.Default) {
repository.observeCategoriesWithCovers()
.collectLatest {
commitJob?.join()
updateContent(it)
}
}
}
fun deleteCategories(ids: Set<Long>) {
launchJob(Dispatchers.Default) {
@@ -59,19 +52,13 @@ class FavouritesCategoriesViewModel @Inject constructor(
settings.isAllFavouritesVisible = isVisible
}
fun isEmpty(): Boolean = categories.value.none { it is CategoryListModel }
fun isEmpty(): Boolean = content.value.none { it is CategoryListModel }
fun reorderCategories(oldPos: Int, newPos: Int) {
val prevJob = reorderJob
reorderJob = launchJob(Dispatchers.Default) {
prevJob?.join()
val snapshot = categories.requireValue().toMutableList()
snapshot.move(oldPos, newPos)
val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) {
(it as? CategoryListModel)?.category?.id
}
repository.reorderCategories(ids)
}
val snapshot = content.requireValue().toMutableList()
snapshot.move(oldPos, newPos)
content.value = snapshot
commit(snapshot)
}
fun setIsVisible(ids: Set<Long>, isVisible: Boolean) {
@@ -83,9 +70,42 @@ class FavouritesCategoriesViewModel @Inject constructor(
}
fun getCategories(ids: Set<Long>): ArrayList<FavouriteCategory> {
val items = categories.requireValue()
val items = content.requireValue()
return items.mapNotNullTo(ArrayList(ids.size)) { item ->
(item as? CategoryListModel)?.category?.takeIf { it.id in ids }
}
}
private fun commit(snapshot: List<ListModel>) {
val prevJob = commitJob
commitJob = launchJob {
prevJob?.cancelAndJoin()
delay(500)
val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) {
(it as? CategoryListModel)?.category?.id
}
repository.reorderCategories(ids)
yield()
}
}
private fun updateContent(categories: Map<FavouriteCategory, List<Cover>>) {
content.value = categories.map { (category, covers) ->
CategoryListModel(
mangaCount = covers.size,
covers = covers.take(3),
category = category,
isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources,
)
}.ifEmpty {
listOf(
EmptyState(
icon = R.drawable.ic_empty_favourites,
textPrimary = R.string.text_empty_holder_primary,
textSecondary = R.string.empty_favourite_categories,
actionStringRes = 0,
),
)
}
}
}

View File

@@ -76,10 +76,7 @@ class FavouriteSheet :
putParcelableArrayList(
KEY_MANGA_LIST,
manga.mapTo(ArrayList(manga.size)) {
ParcelableManga(
it,
withChapters = false,
)
ParcelableManga(it)
},
)
}.showDistinct(fm, TAG)

View File

@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTag
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.mapItems
@@ -107,7 +108,7 @@ class HistoryRepository @Inject constructor(
),
)
trackingRepository.syncWithHistory(manga, chapterId)
val chapter = manga.chapters?.find { x -> x.id == chapterId }
val chapter = manga.chapters?.findById(chapterId)
if (chapter != null) {
scrobblers.forEach { it.tryScrobble(manga.id, chapter) }
}
@@ -181,7 +182,7 @@ class HistoryRepository @Inject constructor(
private suspend fun HistoryEntity.recoverIfNeeded(manga: Manga): HistoryEntity {
val chapters = manga.chapters
if (chapters.isNullOrEmpty() || chapters.any { it.id == chapterId }) {
if (chapters.isNullOrEmpty() || chapters.findById(chapterId) != null) {
return this
}
val newChapterId = chapters.getOrNull(

View File

@@ -25,4 +25,5 @@ enum class ListItemType {
DOWNLOAD,
CATEGORY_LARGE,
MANGA_SCROBBLING,
NAV_ITEM,
}

View File

@@ -13,34 +13,39 @@ class TypedListSpacingDecoration(
) : ItemDecoration() {
private val spacingSmall = context.resources.getDimensionPixelOffset(R.dimen.list_spacing_small)
private val spacingNormal = context.resources.getDimensionPixelOffset(R.dimen.list_spacing_normal)
private val spacingNormal =
context.resources.getDimensionPixelOffset(R.dimen.list_spacing_normal)
private val spacingLarge = context.resources.getDimensionPixelOffset(R.dimen.list_spacing_large)
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
state: RecyclerView.State,
) {
val itemType = parent.getChildViewHolder(view)?.itemViewType?.let {
ListItemType.entries.getOrNull(it)
}
when (itemType) {
ListItemType.FILTER_SORT,
ListItemType.FILTER_TAG -> outRect.set(0)
ListItemType.FILTER_TAG,
-> outRect.set(0)
ListItemType.HEADER,
ListItemType.FEED,
ListItemType.EXPLORE_SOURCE_LIST,
ListItemType.MANGA_SCROBBLING,
ListItemType.MANGA_LIST -> outRect.set(0)
ListItemType.MANGA_LIST,
-> outRect.set(0)
ListItemType.DOWNLOAD,
ListItemType.HINT_EMPTY,
ListItemType.MANGA_LIST_DETAILED -> outRect.set(spacingNormal)
ListItemType.MANGA_LIST_DETAILED,
-> outRect.set(spacingNormal)
ListItemType.PAGE_THUMB,
ListItemType.MANGA_GRID -> outRect.set(spacingNormal)
ListItemType.MANGA_GRID,
-> outRect.set(spacingNormal)
ListItemType.EXPLORE_BUTTONS -> outRect.set(spacingNormal)
@@ -53,7 +58,9 @@ class TypedListSpacingDecoration(
ListItemType.EXPLORE_SUGGESTION,
ListItemType.MANGA_NESTED_GROUP,
ListItemType.CATEGORY_LARGE,
null -> outRect.set(0)
ListItemType.NAV_ITEM,
null,
-> outRect.set(0)
ListItemType.TIP -> outRect.set(0) // TODO
}
@@ -70,6 +77,6 @@ class TypedListSpacingDecoration(
private fun Rect.set(spacing: Int) = set(spacing, spacing, spacing, spacing)
private fun ListItemType?.isEdgeToEdge() = this == ListItemType.MANGA_NESTED_GROUP
|| this == ListItemType.FILTER_SORT
|| this == ListItemType.FILTER_TAG
|| this == ListItemType.FILTER_SORT
|| this == ListItemType.FILTER_TAG
}

View File

@@ -5,6 +5,7 @@ import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.creationTime
import org.koitharu.kotatsu.core.util.ext.listFilesRecursive
import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.core.util.ext.toListSorted
@@ -62,7 +63,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
name = f.nameWithoutExtension.toHumanReadable(),
number = i + 1,
source = MangaSource.LOCAL,
uploadDate = f.lastModified(),
uploadDate = f.creationTime,
url = f.toUri().toString(),
scanlator = null,
branch = null,

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.local.data.output
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.zip.ZipOutput
@@ -87,7 +88,7 @@ class LocalMangaDirOutput(
suspend fun deleteChapter(chapterId: Long) {
val chapter = checkNotNull(index.getMangaInfo()?.chapters) {
"No chapters found"
}.first { it.id == chapterId }
}.findById(chapterId) ?: error("Chapter not found")
val chapterDir = File(rootFile, chapterFileName(chapter))
chapterDir.deleteAwait()
index.removeChapter(chapterId)

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.local.domain.model
import androidx.core.net.toFile
import androidx.core.net.toUri
import org.koitharu.kotatsu.core.util.ext.creationTime
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import java.io.File
@@ -15,7 +16,7 @@ data class LocalManga(
private set
get() {
if (field == -1L) {
field = file.lastModified()
field = file.creationTime
}
return field
}

View File

@@ -106,7 +106,7 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
return
}
val intent = Intent(context, LocalChaptersRemoveService::class.java)
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga))
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
ContextCompat.startForegroundService(context, intent)
}

View File

@@ -47,12 +47,20 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
override fun onScrolledToEnd() = viewModel.loadNextPage()
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
override fun onCreateActionMode(
controller: ListSelectionController,
mode: ActionMode,
menu: Menu,
): Boolean {
mode.menuInflater.inflate(R.menu.mode_local, menu)
return super.onCreateActionMode(controller, mode, menu)
}
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
override fun onActionItemClicked(
controller: ListSelectionController,
mode: ActionMode,
item: MenuItem,
): Boolean {
return when (item.itemId) {
R.id.action_remove -> {
showDeletionConfirm(selectedItemsIds, mode)
@@ -83,13 +91,20 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
}
private fun onItemRemoved() {
Snackbar.make(requireViewBinding().recyclerView, R.string.removal_completed, Snackbar.LENGTH_SHORT).show()
Snackbar.make(
requireViewBinding().recyclerView,
R.string.removal_completed,
Snackbar.LENGTH_SHORT
).show()
}
companion object {
fun newInstance() = LocalListFragment().withArgs(1) {
putSerializable(RemoteListFragment.ARG_SOURCE, MangaSource.LOCAL) // required by FilterCoordinator
putSerializable(
RemoteListFragment.ARG_SOURCE,
MangaSource.LOCAL
) // required by FilterCoordinator
}
}
}

View File

@@ -8,6 +8,7 @@ import coil.request.ImageResult
import org.jsoup.HttpStatusException
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
@@ -92,7 +93,7 @@ class CoverRestoreInterceptor @Inject constructor(
private suspend fun restoreBookmarkImpl(bookmark: Bookmark): Boolean {
val repo = repositoryFactory.create(bookmark.manga.source) as? RemoteMangaRepository ?: return false
val chapter = repo.getDetails(bookmark.manga).chapters?.find { it.id == bookmark.chapterId } ?: return false
val chapter = repo.getDetails(bookmark.manga).chapters?.findById(bookmark.chapterId) ?: return false
val page = repo.getPages(chapter)[bookmark.page]
val imageUrl = page.preview.ifNullOrEmpty { page.url }
return if (imageUrl != bookmark.imageUrl) {

View File

@@ -37,6 +37,7 @@ import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.ui.util.OptionsMenuBadgeHelper
@@ -71,8 +72,10 @@ import com.google.android.material.R as materialR
private const val TAG_SEARCH = "search"
@AndroidEntryPoint
class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNavOwner, View.OnClickListener,
View.OnFocusChangeListener, SearchSuggestionListener, MainNavigationDelegate.OnFragmentChangedListener {
class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNavOwner,
View.OnClickListener,
View.OnFocusChangeListener, SearchSuggestionListener,
MainNavigationDelegate.OnFragmentChangedListener {
@Inject
lateinit var settings: AppSettings
@@ -119,7 +122,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
settings = settings,
)
navigationDelegate.addOnFragmentChangedListener(this)
navigationDelegate.onCreate(savedInstanceState)
navigationDelegate.onCreate(this, savedInstanceState)
appUpdateBadge = OptionsMenuBadgeHelper(viewBinding.toolbar, R.id.action_app_update)
@@ -137,8 +140,9 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged)
viewModel.counters.observe(this, ::onCountersChanged)
viewModel.appUpdate.observe(this, MenuInvalidator(this))
viewModel.onFirstStart.observeEvent(this) { OnboardDialogFragment.showWelcome(supportFragmentManager) }
viewModel.isFeedAvailable.observe(this, ::onFeedAvailabilityChanged)
viewModel.onFirstStart.observeEvent(this) {
OnboardDialogFragment.show(supportFragmentManager)
}
searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged)
}
@@ -166,7 +170,8 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
if (menu == null) {
return false
}
menu.findItem(R.id.action_incognito)?.isChecked = searchSuggestionViewModel.isIncognitoModeEnabled.value
menu.findItem(R.id.action_incognito)?.isChecked =
searchSuggestionViewModel.isIncognitoModeEnabled.value
val hasAppUpdate = viewModel.appUpdate.value != null
menu.findItem(R.id.action_app_update)?.isVisible = hasAppUpdate
appUpdateBadge.setBadgeVisible(hasAppUpdate)
@@ -279,17 +284,12 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
startActivity(IntentBuilder(this).manga(manga).build(), options)
}
private fun onCountersChanged(counters: IntArray) {
repeat(counters.size) { i ->
val counter = counters[i]
navigationDelegate.setCounterAt(i, counter)
private fun onCountersChanged(counters: Map<NavItem, Int>) {
counters.forEach { (navItem, counter) ->
navigationDelegate.setCounter(navItem, counter)
}
}
private fun onFeedAvailabilityChanged(isFeedAvailable: Boolean) {
navigationDelegate.setItemVisibility(R.id.nav_feed, isFeedAvailable)
}
private fun onIncognitoModeChanged(isIncognito: Boolean) {
var options = viewBinding.searchView.imeOptions
options = if (isIncognito) {
@@ -362,8 +362,12 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
} else {
SCROLL_FLAG_SCROLL or SCROLL_FLAG_ENTER_ALWAYS or SCROLL_FLAG_SNAP
}
viewBinding.toolbarCard.updateLayoutParams<AppBarLayout.LayoutParams> { scrollFlags = appBarScrollFlags }
viewBinding.insetsHolder.updateLayoutParams<AppBarLayout.LayoutParams> { scrollFlags = appBarScrollFlags }
viewBinding.toolbarCard.updateLayoutParams<AppBarLayout.LayoutParams> {
scrollFlags = appBarScrollFlags
}
viewBinding.insetsHolder.updateLayoutParams<AppBarLayout.LayoutParams> {
scrollFlags = appBarScrollFlags
}
viewBinding.toolbarCard.background = if (isOpened) {
null
} else {
@@ -387,7 +391,11 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
Manifest.permission.POST_NOTIFICATIONS,
) != PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
1
)
}
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.main.ui
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.activity.OnBackPressedCallback
import androidx.annotation.IdRes
@@ -8,16 +9,28 @@ import androidx.core.view.isEmpty
import androidx.core.view.iterator
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.google.android.material.navigation.NavigationBarView
import com.google.android.material.transition.MaterialFadeThrough
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.explore.ui.ExploreFragment
import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment
import org.koitharu.kotatsu.history.ui.HistoryListFragment
import org.koitharu.kotatsu.local.ui.LocalListFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment
import org.koitharu.kotatsu.tracker.ui.feed.FeedFragment
import java.util.LinkedList
@@ -62,11 +75,11 @@ class MainNavigationDelegate(
navBar.selectedItemId = R.id.nav_history
}
fun onCreate(savedInstanceState: Bundle?) {
fun onCreate(lifecycleOwner: LifecycleOwner, savedInstanceState: Bundle?) {
if (navBar.menu.isEmpty()) {
val menuRes = if (settings.isFavoritesNavItemFirst) R.menu.nav_bottom_alt else R.menu.nav_bottom
navBar.inflateMenu(menuRes)
createMenu(settings.mainNavItems, navBar.menu)
}
observeSettings(lifecycleOwner)
val fragment = primaryFragment
if (fragment != null) {
onFragmentChanged(fragment, fromUser = false)
@@ -84,12 +97,11 @@ class MainNavigationDelegate(
}
}
fun setCounterAt(position: Int, counter: Int) {
val id = navBar.menu.getItem(position).itemId
setCounter(id, counter)
fun setCounter(item: NavItem, counter: Int) {
setCounter(item.id, counter)
}
fun setCounter(@IdRes id: Int, counter: Int) {
private fun setCounter(@IdRes id: Int, counter: Int) {
if (counter == 0) {
navBar.getBadge(id)?.isVisible = false
} else {
@@ -123,9 +135,12 @@ class MainNavigationDelegate(
return setPrimaryFragment(
when (itemId) {
R.id.nav_history -> HistoryListFragment()
R.id.nav_favourites -> FavouritesContainerFragment()
R.id.nav_favorites -> FavouritesContainerFragment()
R.id.nav_explore -> ExploreFragment()
R.id.nav_feed -> FeedFragment()
R.id.nav_local -> LocalListFragment.newInstance()
R.id.nav_suggestions -> SuggestionsFragment()
R.id.nav_bookmarks -> BookmarksFragment()
else -> return false
},
)
@@ -133,9 +148,12 @@ class MainNavigationDelegate(
private fun getItemId(fragment: Fragment) = when (fragment) {
is HistoryListFragment -> R.id.nav_history
is FavouritesContainerFragment -> R.id.nav_favourites
is FavouritesContainerFragment -> R.id.nav_favorites
is ExploreFragment -> R.id.nav_explore
is FeedFragment -> R.id.nav_feed
is LocalListFragment -> R.id.nav_local
is SuggestionsFragment -> R.id.nav_suggestions
is BookmarksFragment -> R.id.nav_bookmarks
else -> 0
}
@@ -157,6 +175,24 @@ class MainNavigationDelegate(
listeners.forEach { it.onFragmentChanged(fragment, fromUser) }
}
private fun createMenu(items: List<NavItem>, menu: Menu) {
for (item in items) {
menu.add(Menu.NONE, item.id, Menu.NONE, item.title)
.setIcon(item.icon)
}
}
private fun observeSettings(lifecycleOwner: LifecycleOwner) {
settings.observe()
.filter { x -> x == AppSettings.KEY_TRACKER_ENABLED || x == AppSettings.KEY_SUGGESTIONS }
.onStart { emit("") }
.flowOn(Dispatchers.Default)
.onEach {
setItemVisibility(R.id.nav_suggestions, settings.isSuggestionsEnabled)
setItemVisibility(R.id.nav_feed, settings.isTrackerEnabled)
}.launchIn(lifecycleOwner.lifecycleScope)
}
private fun firstItem(): MenuItem? {
val menu = navBar.menu
for (item in menu) {

View File

@@ -12,7 +12,7 @@ import kotlinx.coroutines.plus
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.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.main.domain.ReadingResumeEnabledUseCase
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import java.util.EnumMap
import javax.inject.Inject
@HiltViewModel
@@ -42,23 +43,20 @@ class MainViewModel @Inject constructor(
initialValue = false,
)
val isFeedAvailable = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_TRACKER_ENABLED,
valueProducer = { isTrackerEnabled },
)
val appUpdate = appUpdateRepository.observeAvailableUpdate()
val counters = combine(
trackingRepository.observeUpdatedMangaCount(),
observeNewSourcesCount(),
) { tracks, newSources ->
intArrayOf(0, 0, newSources, tracks)
val em = EnumMap<NavItem, Int>(NavItem::class.java)
em[NavItem.EXPLORE] = newSources
em[NavItem.FEED] = tracks
em
}.stateIn(
scope = viewModelScope + Dispatchers.Default,
started = SharingStarted.WhileSubscribed(5000),
initialValue = IntArray(4),
initialValue = emptyMap<NavItem, Int>(),
)
init {

View File

@@ -2,6 +2,12 @@ package org.koitharu.kotatsu.reader.domain
import android.util.LongSparseArray
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -17,24 +23,32 @@ class ChaptersLoader @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
private val chapters = LongSparseArray<MangaChapter>()
private val chapters = MutableStateFlow(LongSparseArray<MangaChapter>(0))
private val chapterPages = ChapterPages()
private val mutex = Mutex()
val size: Int
get() = chapters.size()
val size: Int // TODO flow
get() = chapters.value.size()
suspend fun init(manga: DoubleManga) = mutex.withLock {
chapters.clear()
manga.chapters?.forEach {
chapters.put(it.id, it)
fun init(scope: CoroutineScope, manga: Flow<DoubleManga>) = scope.launch {
manga.collect {
val ch = it.chapters.orEmpty()
val longSparseArray = LongSparseArray<MangaChapter>(ch.size)
ch.forEach { x -> longSparseArray.put(x.id, x) }
mutex.withLock {
chapters.value = longSparseArray
}
}
}
suspend fun loadPrevNextChapter(manga: DoubleManga, currentId: Long, isNext: Boolean) {
val chapters = manga.chapters ?: return
val predicate: (MangaChapter) -> Boolean = { it.id == currentId }
val index = if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate)
val index = if (isNext) {
chapters.indexOfFirst(predicate)
} else {
chapters.indexOfLast(predicate)
}
if (index == -1) return
val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return
val newPages = loadChapter(newChapter.id)
@@ -65,7 +79,11 @@ class ChaptersLoader @Inject constructor(
}
}
fun peekChapter(chapterId: Long): MangaChapter? = chapters[chapterId]
fun peekChapter(chapterId: Long): MangaChapter? = chapters.value[chapterId]
suspend fun awaitChapter(chapterId: Long): MangaChapter? = chapters.mapNotNull { x ->
x[chapterId]
}.firstOrNull()
fun getPages(chapterId: Long): List<ReaderPage> {
return chapterPages.subList(chapterId)
@@ -82,7 +100,7 @@ class ChaptersLoader @Inject constructor(
fun snapshot() = chapterPages.toList()
private suspend fun loadChapter(chapterId: Long): List<ReaderPage> {
val chapter = checkNotNull(chapters[chapterId]) { "Requested chapter not found" }
val chapter = checkNotNull(awaitChapter(chapterId)) { "Requested chapter not found" }
val repo = mangaRepositoryFactory.create(chapter.source)
return repo.getPages(chapter).mapIndexed { index, page ->
ReaderPage(page, index, chapterId)

View File

@@ -5,16 +5,14 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.getParcelableCompat
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
@@ -24,23 +22,29 @@ import javax.inject.Inject
import kotlin.math.roundToInt
@AndroidEntryPoint
class ChaptersSheet : BaseAdaptiveSheet<SheetChaptersBinding>(), OnListItemClickListener<ChapterListItem> {
class ChaptersSheet : BaseAdaptiveSheet<SheetChaptersBinding>(),
OnListItemClickListener<ChapterListItem> {
@Inject
lateinit var settings: AppSettings
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetChaptersBinding {
private val viewModel: ReaderViewModel by activityViewModels()
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
): SheetChaptersBinding {
return SheetChaptersBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetChaptersBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
val chapters = arguments?.getParcelableCompat<ParcelableMangaChapters>(ARG_CHAPTERS)?.chapters
val chapters = viewModel.manga?.chapters
if (chapters.isNullOrEmpty()) {
dismissAllowingStateLoss()
return
}
val currentId = requireArguments().getLong(ARG_CURRENT_ID, 0L)
val currentId = viewModel.getCurrentState()?.chapterId ?: 0L
val currentPosition = chapters.indexOfFirst { it.id == currentId }
val items = chapters.mapIndexed { index, chapter ->
chapter.toListItem(
@@ -54,8 +58,11 @@ class ChaptersSheet : BaseAdaptiveSheet<SheetChaptersBinding>(), OnListItemClick
binding.recyclerView.adapter = ChaptersAdapter(this).also { adapter ->
if (currentPosition >= 0) {
val targetPosition = (currentPosition - 1).coerceAtLeast(0)
val offset = (resources.getDimensionPixelSize(R.dimen.chapter_list_item_height) * 0.6).roundToInt()
adapter.setItems(items, RecyclerViewScrollCallback(binding.recyclerView, targetPosition, offset))
val offset =
(resources.getDimensionPixelSize(R.dimen.chapter_list_item_height) * 0.6).roundToInt()
adapter.setItems(
items, RecyclerViewScrollCallback(binding.recyclerView, targetPosition, offset)
)
} else {
adapter.items = items
}
@@ -63,7 +70,8 @@ class ChaptersSheet : BaseAdaptiveSheet<SheetChaptersBinding>(), OnListItemClick
}
override fun onItemClick(item: ChapterListItem, view: View) {
((parentFragment as? OnChapterChangeListener) ?: (activity as? OnChapterChangeListener))?.let {
((parentFragment as? OnChapterChangeListener)
?: (activity as? OnChapterChangeListener))?.let {
dismiss()
it.onChapterChanged(item.chapter)
}
@@ -76,18 +84,8 @@ class ChaptersSheet : BaseAdaptiveSheet<SheetChaptersBinding>(), OnListItemClick
companion object {
private const val ARG_CHAPTERS = "chapters"
private const val ARG_CURRENT_ID = "current_id"
private const val TAG = "ChaptersBottomSheet"
fun show(
fm: FragmentManager,
chapters: List<MangaChapter>,
currentId: Long,
) = ChaptersSheet().withArgs(2) {
putParcelable(ARG_CHAPTERS, ParcelableMangaChapters(chapters))
putLong(ARG_CURRENT_ID, currentId)
}.showDistinct(fm, TAG)
fun show(fm: FragmentManager) = ChaptersSheet().showDistinct(fm, TAG)
}
}

View File

@@ -44,6 +44,7 @@ import org.koitharu.kotatsu.core.util.GridTouchHelper
import org.koitharu.kotatsu.core.util.IdlingDetector
import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.core.util.ext.hasGlobalPoint
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.isRtl
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
@@ -180,11 +181,7 @@ class ReaderActivity :
}
R.id.action_chapters -> {
ChaptersSheet.show(
supportFragmentManager,
viewModel.manga?.chapters.orEmpty(),
viewModel.getCurrentState()?.chapterId ?: 0L,
)
ChaptersSheet.show(supportFragmentManager)
}
R.id.action_pages_thumbs -> {
@@ -309,22 +306,20 @@ class ReaderActivity :
private fun setUiIsVisible(isUiVisible: Boolean) {
if (viewBinding.appbarTop.isVisible != isUiVisible) {
val transition = TransitionSet()
.setOrdering(TransitionSet.ORDERING_TOGETHER)
.addTransition(Slide(Gravity.TOP).addTarget(viewBinding.appbarTop))
.addTransition(Fade().addTarget(viewBinding.infoBar))
viewBinding.appbarBottom?.let { bottomBar ->
transition.addTransition(Slide(Gravity.BOTTOM).addTarget(bottomBar))
if (isAnimationsEnabled) {
val transition = TransitionSet()
.setOrdering(TransitionSet.ORDERING_TOGETHER)
.addTransition(Slide(Gravity.TOP).addTarget(viewBinding.appbarTop))
.addTransition(Fade().addTarget(viewBinding.infoBar))
viewBinding.appbarBottom?.let { bottomBar ->
transition.addTransition(Slide(Gravity.BOTTOM).addTarget(bottomBar))
}
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
}
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
viewBinding.appbarTop.isVisible = isUiVisible
viewBinding.appbarBottom?.isVisible = isUiVisible
viewBinding.infoBar.isGone = isUiVisible || (!viewModel.isInfoBarEnabled.value)
if (isUiVisible) {
showSystemUI()
} else {
hideSystemUI()
}
systemUiController.setSystemUiVisible(isUiVisible)
}
}
@@ -405,7 +400,7 @@ class ReaderActivity :
.setAction(ACTION_MANGA_READ)
fun manga(manga: Manga) = apply {
intent.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
intent.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
}
fun mangaId(mangaId: Long) = apply {

View File

@@ -87,7 +87,8 @@ class ReaderViewModel @Inject constructor(
private var pageSaveJob: Job? = null
private var bookmarkJob: Job? = null
private var stateChangeJob: Job? = null
private val currentState = MutableStateFlow<ReaderState?>(savedStateHandle[ReaderActivity.EXTRA_STATE])
private val currentState =
MutableStateFlow<ReaderState?>(savedStateHandle[ReaderActivity.EXTRA_STATE])
private val mangaData = MutableStateFlow(intent.manga?.let { DoubleManga(it) })
private val mangaFlow: Flow<Manga?>
get() = mangaData.map { it?.any }
@@ -317,8 +318,9 @@ class ReaderViewModel @Inject constructor(
?: throw NotFoundException("Cannot find manga", ""),
)
mangaData.value = manga
manga = doubleMangaLoadUseCase(intent)
chaptersLoader.init(manga)
val mangaFlow = doubleMangaLoadUseCase(intent)
manga = mangaFlow.first { x -> x.any != null }
chaptersLoader.init(viewModelScope, mangaFlow)
// determine mode
val singleManga = manga.requireAny()
// obtain state
@@ -328,7 +330,7 @@ class ReaderViewModel @Inject constructor(
} ?: ReaderState(singleManga, preselectedBranch)
}
val mode = detectReaderModeUseCase.invoke(singleManga, currentState.value)
val branch = chaptersLoader.peekChapter(currentState.value?.chapterId ?: 0L)?.branch
val branch = chaptersLoader.awaitChapter(currentState.value?.chapterId ?: 0L)?.branch
mangaData.value = manga.filterChapters(branch)
readerMode.value = mode

View File

@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import kotlin.math.roundToLong
private const val MAX_DELAY = 20L
private const val MAX_DELAY = 8L
private const val MAX_SWITCH_DELAY = 10_000L
private const val INTERACTION_SKIP_MS = 2_000L
private const val SPEED_FACTOR_DELTA = 0.02f

View File

@@ -152,7 +152,7 @@ class ColorFilterConfigActivity :
fun newIntent(context: Context, manga: Manga, page: MangaPage) =
Intent(context, ColorFilterConfigActivity::class.java)
.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
.putExtra(EXTRA_MANGA, ParcelableManga(manga))
.putExtra(EXTRA_PAGES, ParcelableMangaPage(page))
}
}

View File

@@ -24,6 +24,7 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.plus
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetPagesBinding
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
@@ -84,6 +85,7 @@ class PagesThumbnailsSheet :
viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged)
viewModel.branch.observe(viewLifecycleOwner, ::updateTitle)
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) }
}
override fun onDestroyView() {
@@ -190,7 +192,7 @@ class PagesThumbnailsSheet :
fun show(fm: FragmentManager, manga: Manga, chapterId: Long, currentPage: Int = -1) {
PagesThumbnailsSheet().withArgs(3) {
putParcelable(ARG_MANGA, ParcelableManga(manga, withChapters = true))
putParcelable(ARG_MANGA, ParcelableManga(manga))
putLong(ARG_CHAPTER_ID, chapterId)
putInt(ARG_CURRENT_PAGE, currentPage)
}.showDistinct(fm, TAG)

View File

@@ -1,19 +1,26 @@
package org.koitharu.kotatsu.reader.ui.thumbnails
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.firstNotNull
import org.koitharu.kotatsu.core.util.ext.firstNotNullOrNull
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.details.domain.DoubleMangaLoadUseCase
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.reader.domain.ChaptersLoader
import javax.inject.Inject
@@ -30,13 +37,12 @@ class PagesThumbnailsViewModel @Inject constructor(
val manga = savedStateHandle.require<ParcelableManga>(PagesThumbnailsSheet.ARG_MANGA).manga
private val repository = mangaRepositoryFactory.create(manga.source)
private val mangaDetails = SuspendLazy {
doubleMangaLoadUseCase(manga).let {
val b = manga.chapters?.find { ch -> ch.id == initialChapterId }?.branch
branch.value = b
it.filterChapters(b)
}
}
private val mangaDetails = doubleMangaLoadUseCase(manga).map {
val b = manga.chapters?.findById(initialChapterId)?.branch
branch.value = b
it.filterChapters(b)
}.withErrorHandling()
.stateIn(viewModelScope, SharingStarted.Lazily, null)
private var loadingJob: Job? = null
private var loadingPrevJob: Job? = null
private var loadingNextJob: Job? = null
@@ -46,8 +52,9 @@ class PagesThumbnailsViewModel @Inject constructor(
val branch = MutableStateFlow<String?>(null)
init {
loadingJob = launchJob(Dispatchers.Default) {
chaptersLoader.init(mangaDetails.get())
loadingJob = launchLoadingJob(Dispatchers.Default) {
chaptersLoader.init(viewModelScope, mangaDetails.filterNotNull())
mangaDetails.first { x -> x?.hasChapter(initialChapterId) == true }
chaptersLoader.loadSingleChapter(initialChapterId)
updateList()
}
@@ -55,7 +62,7 @@ class PagesThumbnailsViewModel @Inject constructor(
fun allowLoadAbove() {
if (!isLoadAboveAllowed) {
loadingJob = launchJob(Dispatchers.Default) {
loadingJob = launchLoadingJob(Dispatchers.Default) {
isLoadAboveAllowed = true
updateList()
}
@@ -78,23 +85,18 @@ class PagesThumbnailsViewModel @Inject constructor(
private fun loadPrevNextChapter(isNext: Boolean): Job = launchLoadingJob(Dispatchers.Default) {
val currentId = (if (isNext) chaptersLoader.last() else chaptersLoader.first()).chapterId
chaptersLoader.loadPrevNextChapter(mangaDetails.get(), currentId, isNext)
chaptersLoader.loadPrevNextChapter(mangaDetails.firstNotNull(), currentId, isNext)
updateList()
}
private suspend fun updateList() {
val snapshot = chaptersLoader.snapshot()
val mangaChapters = mangaDetails.tryGet().getOrNull()?.chapters.orEmpty()
val hasPrevChapter = isLoadAboveAllowed && snapshot.firstOrNull()?.chapterId != mangaChapters.firstOrNull()?.id
val hasNextChapter = snapshot.lastOrNull()?.chapterId != mangaChapters.lastOrNull()?.id
val mangaChapters = mangaDetails.firstNotNullOrNull()?.chapters.orEmpty()
val pages = buildList(snapshot.size + chaptersLoader.size + 2) {
if (hasPrevChapter) {
add(LoadingFooter(-1))
}
var previousChapterId = 0L
for (page in snapshot) {
if (page.chapterId != previousChapterId) {
chaptersLoader.peekChapter(page.chapterId)?.let {
chaptersLoader.awaitChapter(page.chapterId)?.let {
add(ListHeader(it.name))
}
previousChapterId = page.chapterId
@@ -105,9 +107,6 @@ class PagesThumbnailsViewModel @Inject constructor(
page = page,
)
}
if (hasNextChapter) {
add(LoadingFooter(1))
}
}
thumbnails.value = pages
}

View File

@@ -8,7 +8,6 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
@@ -22,7 +21,6 @@ class PageThumbnailAdapter(
init {
addDelegate(ListItemType.PAGE_THUMB, pageThumbnailAD(coil, lifecycleOwner, clickListener))
addDelegate(ListItemType.HEADER, listHeaderAD(null))
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
}
override fun getSectionText(context: Context, position: Int): CharSequence? {

View File

@@ -19,6 +19,7 @@ import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.databinding.ActivityScrobblerConfigBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
@@ -115,13 +116,7 @@ class ScrobblerConfigActivity : BaseActivity<ActivityScrobblerConfigBinding>(),
}
private fun onLoadingStateChanged(isLoading: Boolean) {
viewBinding.progressBar.run {
if (isLoading) {
show()
} else {
hide()
}
}
viewBinding.progressBar.showOrHide(isLoading)
}
private fun showUserDialog() {

View File

@@ -204,7 +204,7 @@ class ScrobblingSelectorSheet :
fun show(fm: FragmentManager, manga: Manga, scrobblerService: ScrobblerService?) =
ScrobblingSelectorSheet().withArgs(2) {
putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = false))
putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga))
if (scrobblerService != null) {
putInt(ARG_SCROBBLER, scrobblerService.id)
}

View File

@@ -102,7 +102,7 @@ class MangaListActivity :
fun showPreview(manga: Manga): Boolean = setSideFragment(
PreviewFragment::class.java,
bundleOf(MangaIntent.KEY_MANGA to ParcelableManga(manga, true)),
bundleOf(MangaIntent.KEY_MANGA to ParcelableManga(manga)),
)
fun hidePreview() = setSideFragment(FilterSheetFragment::class.java, null)

View File

@@ -32,7 +32,8 @@ class SearchEditText @JvmOverloads constructor(
) : AppCompatEditText(context, attrs, defStyleAttr) {
var searchSuggestionListener: SearchSuggestionListener? = null
private val clearIcon = ContextCompat.getDrawable(context, materialR.drawable.abc_ic_clear_material)
private val clearIcon =
ContextCompat.getDrawable(context, materialR.drawable.abc_ic_clear_material)
private var isEmpty = text.isNullOrEmpty()
init {
@@ -52,12 +53,21 @@ class SearchEditText @JvmOverloads constructor(
if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
if (hasFocus()) {
clearFocus()
// return true
}
}
return super.onKeyPreIme(keyCode, event)
}
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
if (keyCode == KeyEvent.KEYCODE_ENTER && event.hasNoModifiers() && query.isNotEmpty()) {
cancelLongPress()
searchSuggestionListener?.onQueryClick(query, submit = true)
clearFocus()
return true
}
return super.onKeyUp(keyCode, event)
}
override fun onEditorAction(actionCode: Int) {
super.onEditorAction(actionCode)
if (actionCode == EditorInfo.IME_ACTION_SEARCH) {
@@ -88,7 +98,8 @@ class SearchEditText @JvmOverloads constructor(
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.action == MotionEvent.ACTION_UP) {
val drawable = compoundDrawablesRelative[DRAWABLE_END] ?: return super.onTouchEvent(event)
val drawable =
compoundDrawablesRelative[DRAWABLE_END] ?: return super.onTouchEvent(event)
val isOnDrawable = drawable.isVisible && if (layoutDirection == LAYOUT_DIRECTION_RTL) {
event.x.toInt() in paddingLeft..(drawable.bounds.width() + paddingLeft)
} else {

View File

@@ -23,7 +23,6 @@ import org.koitharu.kotatsu.core.util.ext.map
import org.koitharu.kotatsu.core.util.ext.postDelayed
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
import org.koitharu.kotatsu.core.util.ext.toList
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.utils.ActivityListPreference
@@ -67,6 +66,7 @@ class AppearanceSettingsFragment :
}
setDefaultValueCompat("")
}
bindNavSummary()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -86,7 +86,8 @@ class AppearanceSettingsFragment :
}
AppSettings.KEY_COLOR_THEME,
AppSettings.KEY_THEME_AMOLED -> {
AppSettings.KEY_THEME_AMOLED,
-> {
postRestart()
}
@@ -94,8 +95,8 @@ class AppearanceSettingsFragment :
AppCompatDelegate.setApplicationLocales(settings.appLocales)
}
AppSettings.KEY_FIRST_NAV_ITEM -> {
activityRecreationHandle.recreate(MainActivity::class.java)
AppSettings.KEY_NAV_MAIN -> {
bindNavSummary()
}
}
}
@@ -127,6 +128,13 @@ class AppearanceSettingsFragment :
}
}
private fun bindNavSummary() {
val pref = findPreference<Preference>(AppSettings.KEY_NAV_MAIN) ?: return
pref.summary = settings.mainNavItems.joinToString {
getString(it.title)
}
}
private class LocaleComparator(context: Context) : Comparator<Locale> {
private val deviceLocales = LocaleManagerCompat.getSystemLocales(context)

View File

@@ -0,0 +1,136 @@
package org.koitharu.kotatsu.settings.nav
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.settings.nav.adapter.navAddAD
import org.koitharu.kotatsu.settings.nav.adapter.navAvailableAD
import org.koitharu.kotatsu.settings.nav.adapter.navConfigAD
@AndroidEntryPoint
class NavConfigFragment : BaseFragment<FragmentSettingsSourcesBinding>(), RecyclerViewOwner,
OnListItemClickListener<NavItem>, View.OnClickListener {
private var reorderHelper: ItemTouchHelper? = null
private val viewModel by viewModels<NavConfigViewModel>()
override val recyclerView: RecyclerView
get() = requireViewBinding().recyclerView
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
): FragmentSettingsSourcesBinding {
return FragmentSettingsSourcesBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(
binding: FragmentSettingsSourcesBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState)
val navConfigAdapter = BaseListAdapter<ListModel>()
.addDelegate(ListItemType.NAV_ITEM, navConfigAD(this))
.addDelegate(ListItemType.FOOTER_LOADING, navAddAD(this))
with(binding.recyclerView) {
setHasFixedSize(true)
adapter = navConfigAdapter
reorderHelper = ItemTouchHelper(ReorderCallback()).also {
it.attachToRecyclerView(this)
}
}
viewModel.content.observe(viewLifecycleOwner, navConfigAdapter)
}
override fun onResume() {
super.onResume()
activity?.setTitle(R.string.main_screen_sections)
}
override fun onDestroyView() {
reorderHelper = null
super.onDestroyView()
}
override fun onWindowInsetsChanged(insets: Insets) {
requireViewBinding().recyclerView.updatePadding(
bottom = insets.bottom,
left = insets.left,
right = insets.right,
)
}
override fun onClick(v: View) {
var dialog: DialogInterface? = null
val listener = OnListItemClickListener<NavItem> { item, _ ->
viewModel.addItem(item)
dialog?.dismiss()
}
dialog = RecyclerViewAlertDialog.Builder<NavItem>(v.context)
.setTitle(R.string.add)
.addAdapterDelegate(navAvailableAD(listener))
.setCancelable(true)
.setItems(viewModel.availableItems)
.setNegativeButton(android.R.string.cancel, null)
.create()
.apply { show() }
}
override fun onItemClick(item: NavItem, view: View) {
viewModel.removeItem(item)
}
override fun onItemLongClick(item: NavItem, view: View): Boolean {
val holder = viewBinding?.recyclerView?.findContainingViewHolder(view) ?: return false
reorderHelper?.startDrag(holder)
return true
}
private inner class ReorderCallback : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.DOWN or ItemTouchHelper.UP,
0,
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean = true
override fun onMoved(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
fromPos: Int,
target: RecyclerView.ViewHolder,
toPos: Int,
x: Int,
y: Int,
) {
super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y)
viewModel.reorder(fromPos, toPos)
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
override fun isLongPressDragEnabled() = false
}
}

View File

@@ -0,0 +1,80 @@
package org.koitharu.kotatsu.settings.nav
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.parsers.util.move
import org.koitharu.kotatsu.settings.nav.model.NavItemAddModel
import javax.inject.Inject
@HiltViewModel
class NavConfigViewModel @Inject constructor(
private val settings: AppSettings,
private val activityRecreationHandle: ActivityRecreationHandle,
) : BaseViewModel() {
private val items = MutableStateFlow(settings.mainNavItems)
val content: StateFlow<List<ListModel>> = items.map { snapshot ->
if (snapshot.size < NavItem.entries.size) {
snapshot + NavItemAddModel(snapshot.size < 5)
} else {
snapshot
}
}.stateIn(
viewModelScope + Dispatchers.Default,
SharingStarted.WhileSubscribed(5000),
emptyList()
)
private var commitJob: Job? = null
val availableItems
get() = items.value.let { snapshot ->
NavItem.entries.filterNot { x -> x in snapshot }
}
fun reorder(fromPos: Int, toPos: Int) {
items.value = items.value.toMutableList().apply {
move(fromPos, toPos)
commit(this)
}
}
fun addItem(item: NavItem) {
items.value = items.value.plus(item).also {
commit(it)
}
}
fun removeItem(item: NavItem) {
items.value = items.value.minus(item).also {
commit(it)
}
}
private fun commit(value: List<NavItem>) {
val prevJob = commitJob
commitJob = launchJob {
prevJob?.cancelAndJoin()
delay(500)
settings.mainNavItems = value
activityRecreationHandle.recreate(MainActivity::class.java)
}
}
}

View File

@@ -0,0 +1,73 @@
package org.koitharu.kotatsu.settings.nav.adapter
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.View
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemNavAvailableBinding
import org.koitharu.kotatsu.databinding.ItemNavConfigBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.settings.nav.model.NavItemAddModel
@SuppressLint("ClickableViewAccessibility")
fun navConfigAD(
clickListener: OnListItemClickListener<NavItem>,
) = adapterDelegateViewBinding<NavItem, ListModel, ItemNavConfigBinding>(
{ layoutInflater, parent -> ItemNavConfigBinding.inflate(layoutInflater, parent, false) },
) {
val eventListener = object : View.OnClickListener, View.OnTouchListener {
override fun onClick(v: View) = clickListener.onItemClick(item, v)
override fun onTouch(v: View?, event: MotionEvent): Boolean =
event.actionMasked == MotionEvent.ACTION_DOWN &&
clickListener.onItemLongClick(item, itemView)
}
binding.imageViewRemove.setOnClickListener(eventListener)
binding.imageViewReorder.setOnTouchListener(eventListener)
bind {
with(binding.textViewTitle) {
setText(item.title)
setCompoundDrawablesRelativeWithIntrinsicBounds(item.icon, 0, 0, 0)
}
}
}
fun navAvailableAD(
clickListener: OnListItemClickListener<NavItem>,
) = adapterDelegateViewBinding<NavItem, NavItem, ItemNavAvailableBinding>(
{ layoutInflater, parent -> ItemNavAvailableBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener { v ->
clickListener.onItemClick(item, v)
}
bind {
with(binding.root) {
setText(item.title)
setCompoundDrawablesRelativeWithIntrinsicBounds(item.icon, 0, 0, 0)
}
}
}
fun navAddAD(
clickListener: View.OnClickListener,
) = adapterDelegateViewBinding<NavItemAddModel, ListModel, ItemNavAvailableBinding>(
{ layoutInflater, parent -> ItemNavAvailableBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener(clickListener)
binding.root.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_add, 0, 0, 0)
bind {
with(binding.root) {
setText(if (item.canAdd) R.string.add else R.string.items_limit_exceeded)
isEnabled = item.canAdd
}
}
}

View File

@@ -0,0 +1,10 @@
package org.koitharu.kotatsu.settings.nav.model
import org.koitharu.kotatsu.list.ui.model.ListModel
data class NavItemAddModel(
val canAdd: Boolean,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean = other is NavItemAddModel
}

View File

@@ -28,7 +28,10 @@ class NewSourcesDialogFragment :
private val viewModel by viewModels<NewSourcesViewModel>()
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogOnboardBinding {
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
): DialogOnboardBinding {
return DialogOnboardBinding.inflate(inflater, container, false)
}
@@ -54,6 +57,8 @@ class NewSourcesDialogFragment :
override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) = Unit
override fun onItemLiftClick(item: SourceConfigItem.SourceItem) = Unit
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
viewModel.onItemEnabledChanged(item, isEnabled)
}

View File

@@ -4,7 +4,6 @@ import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -13,7 +12,6 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showAllowStateLoss
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.DialogOnboardBinding
import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocaleListener
import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocalesAdapter
@@ -25,14 +23,6 @@ class OnboardDialogFragment :
DialogInterface.OnClickListener, SourceLocaleListener {
private val viewModel by viewModels<OnboardViewModel>()
private var isWelcome: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.run {
isWelcome = getBoolean(ARG_WELCOME, false)
}
}
override fun onCreateViewBinding(
inflater: LayoutInflater,
@@ -43,11 +33,7 @@ class OnboardDialogFragment :
super.onBuildDialog(builder)
.setPositiveButton(R.string.done, this)
.setCancelable(false)
if (isWelcome) {
builder.setTitle(R.string.welcome)
} else {
builder.setTitle(R.string.remote_sources)
}
builder.setTitle(R.string.welcome)
return builder
}
@@ -55,11 +41,7 @@ class OnboardDialogFragment :
super.onViewBindingCreated(binding, savedInstanceState)
val adapter = SourceLocalesAdapter(this)
binding.recyclerView.adapter = adapter
if (isWelcome) {
binding.textViewTitle.setText(R.string.onboard_text)
} else {
binding.textViewTitle.isVisible = false
}
binding.textViewTitle.setText(R.string.onboard_text)
viewModel.list.observe(viewLifecycleOwner, adapter)
}
@@ -76,14 +58,7 @@ class OnboardDialogFragment :
companion object {
private const val TAG = "OnboardDialog"
private const val ARG_WELCOME = "welcome"
fun show(fm: FragmentManager) = OnboardDialogFragment().show(fm, TAG)
fun showWelcome(fm: FragmentManager) {
OnboardDialogFragment().withArgs(1) {
putBoolean(ARG_WELCOME, true)
}.showAllowStateLoss(fm, TAG)
}
fun show(fm: FragmentManager) = OnboardDialogFragment().showAllowStateLoss(fm, TAG)
}
}

View File

@@ -49,7 +49,7 @@ class SourceSettingsViewModel @Inject constructor(
.scheme("https")
.host(repository.domain)
.build()
cookieJar.removeCookies(url)
cookieJar.removeCookies(url, null)
onActionDone.call(ReversibleAction(R.string.cookies_cleared, null))
loadUsername()
}

View File

@@ -0,0 +1,186 @@
package org.koitharu.kotatsu.settings.sources
import androidx.core.os.LocaleListCompat
import androidx.room.InvalidationTracker
import dagger.hilt.android.ViewModelLifecycle
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_SOURCES
import org.koitharu.kotatsu.core.model.getLocaleTitle
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.map
import org.koitharu.kotatsu.core.util.ext.toEnumSet
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
import java.util.Locale
import java.util.TreeMap
import javax.inject.Inject
@ViewModelScoped
class SourcesListProducer @Inject constructor(
lifecycle: ViewModelLifecycle,
private val repository: MangaSourcesRepository,
private val settings: AppSettings,
) : InvalidationTracker.Observer(TABLE_SOURCES) {
private val scope = lifecycle.lifecycleScope
private var query: String = ""
private val expanded = HashSet<String?>()
val list = MutableStateFlow(emptyList<SourceConfigItem>())
private var job = scope.launch(Dispatchers.Default) {
list.value = buildList()
}
init {
settings.observe()
.filter { it == AppSettings.KEY_TIPS_CLOSED || it == AppSettings.KEY_DISABLE_NSFW }
.flowOn(Dispatchers.Default)
.onEach { onInvalidated(emptySet()) }
.launchIn(scope)
}
override fun onInvalidated(tables: Set<String>) {
val prevJob = job
job = scope.launch(Dispatchers.Default) {
prevJob.cancelAndJoin()
list.update { buildList() }
}
}
fun setQuery(value: String) {
this.query = value
onInvalidated(emptySet())
}
fun expandCollapse(group: String?) {
if (!expanded.remove(group)) {
expanded.add(group)
}
onInvalidated(emptySet())
}
private suspend fun buildList(): List<SourceConfigItem> {
val allSources = repository.allMangaSources
val enabledSources = repository.getEnabledSources()
val isNsfwDisabled = settings.isNsfwContentDisabled
val withTip = settings.isTipEnabled(TIP_REORDER)
val enabledSet = enabledSources.toEnumSet()
if (query.isNotEmpty()) {
return allSources.mapNotNull {
if (!it.title.contains(query, ignoreCase = true)) {
return@mapNotNull null
}
SourceConfigItem.SourceItem(
source = it,
summary = it.getLocaleTitle(),
isEnabled = it in enabledSet,
isDraggable = false,
isAvailable = !isNsfwDisabled || !it.isNsfw(),
)
}.ifEmpty {
listOf(SourceConfigItem.EmptySearchResult)
}
}
val map = allSources.groupByTo(TreeMap(LocaleKeyComparator())) {
if (it in enabledSet) {
KEY_ENABLED
} else {
it.locale
}
}
map.remove(KEY_ENABLED)
val result = ArrayList<SourceConfigItem>(allSources.size + map.size + 2)
if (enabledSources.isNotEmpty()) {
result += SourceConfigItem.Header(R.string.enabled_sources)
if (withTip) {
result += SourceConfigItem.Tip(
TIP_REORDER,
R.drawable.ic_tap_reorder,
R.string.sources_reorder_tip,
)
}
enabledSources.mapTo(result) {
SourceConfigItem.SourceItem(
source = it,
summary = it.getLocaleTitle(),
isEnabled = true,
isDraggable = true,
isAvailable = false,
)
}
}
if (enabledSources.size != allSources.size) {
result += SourceConfigItem.Header(R.string.available_sources)
val comparator = compareBy<MangaSource, String>(AlphanumComparator()) { it.name }
for ((key, list) in map) {
list.sortWith(comparator)
val isExpanded = key in expanded
result += SourceConfigItem.LocaleGroup(
localeId = key,
title = getLocaleTitle(key),
isExpanded = isExpanded,
)
if (isExpanded) {
list.mapTo(result) {
SourceConfigItem.SourceItem(
source = it,
summary = null,
isEnabled = false,
isDraggable = false,
isAvailable = !isNsfwDisabled || !it.isNsfw(),
)
}
}
}
}
return result
}
private class LocaleKeyComparator : Comparator<String?> {
private val deviceLocales = LocaleListCompat.getAdjustedDefault()
.map { it.language }
override fun compare(a: String?, b: String?): Int {
when {
a == b -> return 0
a == null -> return 1
b == null -> return -1
}
val ai = deviceLocales.indexOf(a!!)
val bi = deviceLocales.indexOf(b!!)
return when {
ai < 0 && bi < 0 -> a.compareTo(b)
ai < 0 -> 1
bi < 0 -> -1
else -> ai.compareTo(bi)
}
}
}
companion object {
private fun getLocaleTitle(localeKey: String?): String? {
val locale = Locale(localeKey ?: return null)
return locale.getDisplayLanguage(locale).toTitleCase(locale)
}
private const val KEY_ENABLED = "!"
const val TIP_REORDER = "src_reorder"
}
}

View File

@@ -27,7 +27,6 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
@@ -56,7 +55,10 @@ class SourcesManageFragment :
container: ViewGroup?,
) = FragmentSettingsSourcesBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: FragmentSettingsSourcesBinding, savedInstanceState: Bundle?) {
override fun onViewBindingCreated(
binding: FragmentSettingsSourcesBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState)
val sourcesAdapter = SourceConfigAdapter(this, coil, viewLifecycleOwner)
with(binding.recyclerView) {
@@ -67,7 +69,10 @@ class SourcesManageFragment :
}
}
viewModel.content.observe(viewLifecycleOwner, sourcesAdapter)
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
viewModel.onActionDone.observeEvent(
viewLifecycleOwner,
ReversibleActionObserver(binding.recyclerView)
)
addMenuProvider(SourcesMenuProvider())
}
@@ -94,6 +99,10 @@ class SourcesManageFragment :
(activity as? SettingsActivity)?.openFragment(fragment, false)
}
override fun onItemLiftClick(item: SourceConfigItem.SourceItem) {
viewModel.bringToTop(item.source)
}
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
viewModel.setEnabled(item.source, isEnabled)
}
@@ -127,11 +136,6 @@ class SourcesManageFragment :
true
}
R.id.action_locales -> {
OnboardDialogFragment.show(childFragmentManager)
true
}
R.id.action_no_nsfw -> {
settings.isNsfwContentDisabled = !menuItem.isChecked
true
@@ -181,7 +185,7 @@ class SourcesManageFragment :
target: RecyclerView.ViewHolder,
toPos: Int,
x: Int,
y: Int
y: Int,
) {
super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y)
viewModel.reorderSources(fromPos, toPos)
@@ -196,7 +200,10 @@ class SourcesManageFragment :
target.bindingAdapterPosition,
)
override fun getDragDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
override fun getDragDirs(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
): Int {
val item = viewHolder.getItem(SourceConfigItem.SourceItem::class.java)
return if (item != null && item.isDraggable) {
super.getDragDirs(recyclerView, viewHolder)
@@ -205,7 +212,10 @@ class SourcesManageFragment :
}
}
override fun getSwipeDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
override fun getSwipeDirs(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
): Int {
val item = viewHolder.getItem(SourceConfigItem.Tip::class.java)
return if (item != null) {
super.getSwipeDirs(recyclerView, viewHolder)

View File

@@ -1,86 +1,59 @@
package org.koitharu.kotatsu.settings.sources
import androidx.annotation.CheckResult
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getLocaleTitle
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.removeObserverAsync
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.map
import org.koitharu.kotatsu.core.util.ext.toEnumSet
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.move
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
import java.util.Locale
import java.util.TreeMap
import javax.inject.Inject
private const val KEY_ENABLED = "!"
private const val TIP_REORDER = "src_reorder"
@HiltViewModel
class SourcesManageViewModel @Inject constructor(
private val database: MangaDatabase,
private val settings: AppSettings,
private val repository: MangaSourcesRepository,
private val listProducer: SourcesListProducer,
) : BaseViewModel() {
private val expandedGroups = MutableStateFlow(emptySet<String?>())
private var searchQuery = MutableStateFlow<String?>(null)
private var reorderJob: Job? = null
val content = combine(
repository.observeEnabledSources(),
expandedGroups,
searchQuery,
observeTip(),
settings.observeAsFlow(AppSettings.KEY_DISABLE_NSFW) { isNsfwContentDisabled },
) { sources, groups, query, tip, noNsfw ->
buildList(sources, groups, query, tip, noNsfw)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val content = listProducer.list
val onActionDone = MutableEventFlow<ReversibleAction>()
private var commitJob: Job? = null
init {
launchJob(Dispatchers.Default) {
database.invalidationTracker.addObserver(listProducer)
}
}
override fun onCleared() {
super.onCleared()
database.invalidationTracker.removeObserverAsync(listProducer)
}
fun reorderSources(oldPos: Int, newPos: Int) {
val snapshot = content.value.toMutableList()
val prevJob = reorderJob
reorderJob = launchJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) {
return@launchJob
}
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) {
return@launchJob
}
delay(100)
snapshot.move(oldPos, newPos)
val newSourcesList = snapshot.mapNotNull { x ->
if (x is SourceConfigItem.SourceItem && x.isDraggable) {
x.source
} else {
null
}
}
repository.setPositions(newSourcesList)
if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) {
return
}
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) {
return
}
snapshot.move(oldPos, newPos)
content.value = snapshot
commit(snapshot)
}
fun canReorder(oldPos: Int, newPos: Int): Boolean {
@@ -98,6 +71,32 @@ class SourcesManageViewModel @Inject constructor(
}
}
fun bringToTop(source: MangaSource) {
var oldPos = -1
var newPos = -1
val snapshot = content.value
for ((i, x) in snapshot.withIndex()) {
if (x !is SourceConfigItem.SourceItem) {
continue
}
if (newPos == -1) {
newPos = i
}
if (x.source == source) {
oldPos = i
break
}
}
@Suppress("KotlinConstantConditions")
if (oldPos != -1 && newPos != -1) {
reorderSources(oldPos, newPos)
val revert = ReversibleAction(R.string.moved_to_top) {
reorderSources(newPos, oldPos)
}
onActionDone.call(revert)
}
}
fun disableAll() {
launchJob(Dispatchers.Default) {
repository.disableAllSources()
@@ -105,16 +104,11 @@ class SourcesManageViewModel @Inject constructor(
}
fun expandOrCollapse(headerId: String?) {
val expanded = expandedGroups.value
expandedGroups.value = if (headerId in expanded) {
expanded - headerId
} else {
expanded + headerId
}
listProducer.expandCollapse(headerId)
}
fun performSearch(query: String?) {
searchQuery.value = query?.trim()
listProducer.setQuery(query?.trim().orEmpty())
}
fun onTipClosed(item: SourceConfigItem.Tip) {
@@ -123,113 +117,20 @@ class SourcesManageViewModel @Inject constructor(
}
}
@CheckResult
private fun buildList(
enabledSources: List<MangaSource>,
expanded: Set<String?>,
query: String?,
withTip: Boolean,
isNsfwDisabled: Boolean,
): List<SourceConfigItem> {
val allSources = repository.allMangaSources
val enabledSet = enabledSources.toEnumSet()
if (!query.isNullOrEmpty()) {
return allSources.mapNotNull {
if (!it.title.contains(query, ignoreCase = true)) {
return@mapNotNull null
}
SourceConfigItem.SourceItem(
source = it,
summary = it.getLocaleTitle(),
isEnabled = it in enabledSet,
isDraggable = false,
isAvailable = !isNsfwDisabled || !it.isNsfw(),
)
}.ifEmpty {
listOf(SourceConfigItem.EmptySearchResult)
}
}
val map = allSources.groupByTo(TreeMap(LocaleKeyComparator())) {
if (it in enabledSet) {
KEY_ENABLED
} else {
it.locale
}
}
map.remove(KEY_ENABLED)
val result = ArrayList<SourceConfigItem>(allSources.size + map.size + 2)
if (enabledSources.isNotEmpty()) {
result += SourceConfigItem.Header(R.string.enabled_sources)
if (withTip) {
result += SourceConfigItem.Tip(TIP_REORDER, R.drawable.ic_tap_reorder, R.string.sources_reorder_tip)
}
enabledSources.mapTo(result) {
SourceConfigItem.SourceItem(
source = it,
summary = it.getLocaleTitle(),
isEnabled = true,
isDraggable = true,
isAvailable = false,
)
}
}
if (enabledSources.size != allSources.size) {
result += SourceConfigItem.Header(R.string.available_sources)
val comparator = compareBy<MangaSource, String>(AlphanumComparator()) { it.name }
for ((key, list) in map) {
list.sortWith(comparator)
val isExpanded = key in expanded
result += SourceConfigItem.LocaleGroup(
localeId = key,
title = getLocaleTitle(key),
isExpanded = isExpanded,
)
if (isExpanded) {
list.mapTo(result) {
SourceConfigItem.SourceItem(
source = it,
summary = null,
isEnabled = false,
isDraggable = false,
isAvailable = !isNsfwDisabled || !it.isNsfw(),
)
}
private fun commit(snapshot: List<SourceConfigItem>) {
val prevJob = commitJob
commitJob = launchJob {
prevJob?.cancelAndJoin()
delay(500)
val newSourcesList = snapshot.mapNotNull { x ->
if (x is SourceConfigItem.SourceItem && x.isDraggable) {
x.source
} else {
null
}
}
}
return result
}
private fun getLocaleTitle(localeKey: String?): String? {
val locale = Locale(localeKey ?: return null)
return locale.getDisplayLanguage(locale).toTitleCase(locale)
}
private fun observeTip() = settings.observeAsFlow(AppSettings.KEY_TIPS_CLOSED) {
isTipEnabled(TIP_REORDER)
}
private fun MangaSource.isNsfw() = contentType == ContentType.HENTAI
private class LocaleKeyComparator : Comparator<String?> {
private val deviceLocales = LocaleListCompat.getAdjustedDefault()
.map { it.language }
override fun compare(a: String?, b: String?): Int {
when {
a == b -> return 0
a == null -> return 1
b == null -> return -1
}
val ai = deviceLocales.indexOf(a!!)
val bi = deviceLocales.indexOf(b!!)
return when {
ai < 0 && bi < 0 -> a.compareTo(b)
ai < 0 -> 1
bi < 0 -> -1
else -> ai.compareTo(bi)
}
repository.setPositions(newSourcesList)
yield()
}
}
}

View File

@@ -7,6 +7,7 @@ import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import android.text.style.SuperscriptSpan
import android.view.View
import androidx.appcompat.widget.PopupMenu
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.core.view.isGone
@@ -34,7 +35,13 @@ import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
fun sourceConfigHeaderDelegate() =
adapterDelegateViewBinding<SourceConfigItem.Header, SourceConfigItem, ItemFilterHeaderBinding>(
{ layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) },
{ layoutInflater, parent ->
ItemFilterHeaderBinding.inflate(
layoutInflater,
parent,
false
)
},
) {
bind {
@@ -44,104 +51,121 @@ fun sourceConfigHeaderDelegate() =
fun sourceConfigGroupDelegate(
listener: SourceConfigListener,
) = adapterDelegateViewBinding<SourceConfigItem.LocaleGroup, SourceConfigItem, ItemExpandableBinding>(
{ layoutInflater, parent -> ItemExpandableBinding.inflate(layoutInflater, parent, false) },
) {
) =
adapterDelegateViewBinding<SourceConfigItem.LocaleGroup, SourceConfigItem, ItemExpandableBinding>(
{ layoutInflater, parent -> ItemExpandableBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener {
listener.onHeaderClick(item)
}
binding.root.setOnClickListener {
listener.onHeaderClick(item)
}
bind {
binding.root.text = item.title ?: getString(R.string.various_languages)
binding.root.isChecked = item.isExpanded
bind {
binding.root.text = item.title ?: getString(R.string.various_languages)
binding.root.isChecked = item.isExpanded
}
}
}
fun sourceConfigItemCheckableDelegate(
listener: SourceConfigListener,
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigCheckableBinding>(
{ layoutInflater, parent -> ItemSourceConfigCheckableBinding.inflate(layoutInflater, parent, false) },
) {
) =
adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigCheckableBinding>(
{ layoutInflater, parent ->
ItemSourceConfigCheckableBinding.inflate(
layoutInflater,
parent,
false
)
},
) {
binding.switchToggle.setOnCheckedChangeListener { _, isChecked ->
listener.onItemEnabledChanged(item, isChecked)
}
binding.switchToggle.setOnCheckedChangeListener { _, isChecked ->
listener.onItemEnabledChanged(item, isChecked)
}
bind {
binding.textViewTitle.text = if (item.isNsfw) {
buildSpannedString {
append(item.source.title)
append(' ')
appendNsfwLabel(context)
bind {
binding.textViewTitle.text = if (item.isNsfw) {
buildSpannedString {
append(item.source.title)
append(' ')
appendNsfwLabel(context)
}
} else {
item.source.title
}
binding.switchToggle.isChecked = item.isEnabled
binding.switchToggle.isEnabled = item.isAvailable
binding.textViewDescription.textAndVisible = item.summary
val fallbackIcon =
FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
crossfade(context)
error(fallbackIcon)
placeholder(fallbackIcon)
fallback(fallbackIcon)
source(item.source)
enqueueWith(coil)
}
} else {
item.source.title
}
binding.switchToggle.isChecked = item.isEnabled
binding.switchToggle.isEnabled = item.isAvailable
binding.textViewDescription.textAndVisible = item.summary
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
crossfade(context)
error(fallbackIcon)
placeholder(fallbackIcon)
fallback(fallbackIcon)
source(item.source)
enqueueWith(coil)
}
}
}
fun sourceConfigItemDelegate2(
listener: SourceConfigListener,
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigBinding>(
{ layoutInflater, parent -> ItemSourceConfigBinding.inflate(layoutInflater, parent, false) },
) {
) =
adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigBinding>(
{ layoutInflater, parent ->
ItemSourceConfigBinding.inflate(
layoutInflater,
parent,
false
)
},
) {
val eventListener = View.OnClickListener { v ->
when (v.id) {
R.id.imageView_add -> listener.onItemEnabledChanged(item, true)
R.id.imageView_remove -> listener.onItemEnabledChanged(item, false)
R.id.imageView_config -> listener.onItemSettingsClick(item)
}
}
binding.imageViewRemove.setOnClickListener(eventListener)
binding.imageViewAdd.setOnClickListener(eventListener)
binding.imageViewConfig.setOnClickListener(eventListener)
bind {
binding.textViewTitle.text = if (item.isNsfw) {
buildSpannedString {
append(item.source.title)
append(' ')
appendNsfwLabel(context)
val eventListener = View.OnClickListener { v ->
when (v.id) {
R.id.imageView_add -> listener.onItemEnabledChanged(item, true)
R.id.imageView_remove -> listener.onItemEnabledChanged(item, false)
R.id.imageView_menu -> showSourceMenu(v, item, listener)
}
} else {
item.source.title
}
binding.imageViewAdd.isGone = item.isEnabled || !item.isAvailable
binding.imageViewRemove.isVisible = item.isEnabled
binding.imageViewConfig.isVisible = item.isEnabled
binding.textViewDescription.textAndVisible = item.summary
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
crossfade(context)
error(fallbackIcon)
placeholder(fallbackIcon)
fallback(fallbackIcon)
source(item.source)
enqueueWith(coil)
binding.imageViewRemove.setOnClickListener(eventListener)
binding.imageViewAdd.setOnClickListener(eventListener)
binding.imageViewMenu.setOnClickListener(eventListener)
bind {
binding.textViewTitle.text = if (item.isNsfw) {
buildSpannedString {
append(item.source.title)
append(' ')
appendNsfwLabel(context)
}
} else {
item.source.title
}
binding.imageViewAdd.isGone = item.isEnabled || !item.isAvailable
binding.imageViewRemove.isVisible = item.isEnabled
binding.imageViewMenu.isVisible = item.isEnabled
binding.textViewDescription.textAndVisible = item.summary
val fallbackIcon =
FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
crossfade(context)
error(fallbackIcon)
placeholder(fallbackIcon)
fallback(fallbackIcon)
source(item.source)
enqueueWith(coil)
}
}
}
}
fun sourceConfigTipDelegate(
listener: OnTipCloseListener<SourceConfigItem.Tip>
listener: OnTipCloseListener<SourceConfigItem.Tip>,
) = adapterDelegateViewBinding<SourceConfigItem.Tip, SourceConfigItem, ItemTipBinding>(
{ layoutInflater, parent -> ItemTipBinding.inflate(layoutInflater, parent, false) },
) {
@@ -156,14 +180,37 @@ fun sourceConfigTipDelegate(
}
}
fun sourceConfigEmptySearchDelegate() = adapterDelegate<SourceConfigItem.EmptySearchResult, SourceConfigItem>(
R.layout.item_sources_empty,
) { }
fun sourceConfigEmptySearchDelegate() =
adapterDelegate<SourceConfigItem.EmptySearchResult, SourceConfigItem>(
R.layout.item_sources_empty,
) { }
fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
ForegroundColorSpan(context.getThemeColor(com.google.android.material.R.attr.colorError, Color.RED)),
ForegroundColorSpan(
context.getThemeColor(
com.google.android.material.R.attr.colorError,
Color.RED
)
),
RelativeSizeSpan(0.74f),
SuperscriptSpan(),
) {
append(context.getString(R.string.nsfw))
}
private fun showSourceMenu(
anchor: View,
item: SourceConfigItem.SourceItem,
listener: SourceConfigListener,
) {
val menu = PopupMenu(anchor.context, anchor)
menu.inflate(R.menu.popup_source_config)
menu.setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_settings -> listener.onItemSettingsClick(item)
R.id.action_lift -> listener.onItemLiftClick(item)
}
true
}
menu.show()
}

View File

@@ -7,6 +7,8 @@ interface SourceConfigListener : OnTipCloseListener<SourceConfigItem.Tip> {
fun onItemSettingsClick(item: SourceConfigItem.SourceItem)
fun onItemLiftClick(item: SourceConfigItem.SourceItem)
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean)
fun onHeaderClick(header: SourceConfigItem.LocaleGroup)

View File

@@ -72,11 +72,7 @@ sealed interface SourceConfigItem : ListModel {
}
}
object EmptySearchResult : SourceConfigItem {
override fun equals(other: Any?): Boolean {
return other === EmptySearchResult
}
data object EmptySearchResult : SourceConfigItem {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is EmptySearchResult

View File

@@ -27,7 +27,11 @@ class SuggestionsFragment : MangaListFragment() {
override fun onScrolledToEnd() = Unit
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
override fun onCreateActionMode(
controller: ListSelectionController,
mode: ActionMode,
menu: Menu,
): Boolean {
mode.menuInflater.inflate(R.menu.mode_remote, menu)
return super.onCreateActionMode(controller, mode, menu)
}
@@ -38,6 +42,12 @@ class SuggestionsFragment : MangaListFragment() {
menuInflater.inflate(R.menu.opt_suggestions, menu)
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.action_settings_suggestions)?.isVisible =
menu.findItem(R.id.action_settings) == null
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_update -> {
viewModel.updateSuggestions()
@@ -49,7 +59,7 @@ class SuggestionsFragment : MangaListFragment() {
true
}
R.id.action_settings -> {
R.id.action_settings_suggestions -> {
startActivity(SettingsActivity.newSuggestionsSettingsIntent(requireContext()))
true
}
@@ -60,6 +70,12 @@ class SuggestionsFragment : MangaListFragment() {
companion object {
@Deprecated(
"", ReplaceWith(
"SuggestionsFragment()",
"org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment"
)
)
fun newInstance() = SuggestionsFragment()
}
}

View File

@@ -110,21 +110,26 @@ class Tracker @Inject constructor(
private fun compare(track: MangaTracking, manga: Manga, branch: String?): MangaUpdates.Success {
if (track.isEmpty()) {
// first check or manga was empty on last check
return MangaUpdates.Success(manga, emptyList(), isValid = false)
return MangaUpdates.Success(manga, emptyList(), isValid = false, channelId = null)
}
val chapters = requireNotNull(manga.getChapters(branch))
val newChapters = chapters.takeLastWhile { x -> x.id != track.lastChapterId }
return when {
newChapters.isEmpty() -> {
MangaUpdates.Success(manga, emptyList(), isValid = chapters.lastOrNull()?.id == track.lastChapterId)
MangaUpdates.Success(
manga = manga,
newChapters = emptyList(),
isValid = chapters.lastOrNull()?.id == track.lastChapterId,
channelId = null
)
}
newChapters.size == chapters.size -> {
MangaUpdates.Success(manga, emptyList(), isValid = false)
MangaUpdates.Success(manga, emptyList(), isValid = false, channelId = null)
}
else -> {
MangaUpdates.Success(manga, newChapters, isValid = true)
MangaUpdates.Success(manga, newChapters, isValid = true, channelId = null)
}
}
}

View File

@@ -8,16 +8,17 @@ sealed interface MangaUpdates {
val manga: Manga
class Success(
data class Success(
override val manga: Manga,
val newChapters: List<MangaChapter>,
val isValid: Boolean,
val channelId: String?,
) : MangaUpdates {
fun isNotEmpty() = newChapters.isNotEmpty()
}
class Failure(
data class Failure(
override val manga: Manga,
val error: Throwable?,
) : MangaUpdates {

View File

@@ -34,11 +34,13 @@ import coil.request.ImageRequest
import dagger.Reusable
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
@@ -83,6 +85,8 @@ class TrackWorker @AssistedInject constructor(
logger.log("doWork(): attempt $runAttemptCount")
return try {
doWorkImpl()
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
logger.log("fatal", e)
Result.failure()
@@ -148,19 +152,9 @@ class TrackWorker @AssistedInject constructor(
send(
runCatchingCancellable {
tracker.fetchUpdates(track, commit = true)
.copy(channelId = channelId)
}.onFailure { e ->
if (e is CloudFlareProtectedException) {
CaptchaNotifier(applicationContext).notify(e)
}
logger.log("checkUpdatesAsync", e)
}.onSuccess { updates ->
if (updates.isValid && updates.isNotEmpty()) {
showNotification(
manga = updates.manga,
channelId = channelId,
newChapters = updates.newChapters,
)
}
}.getOrElse { error ->
MangaUpdates.Failure(
manga = track.manga,
@@ -171,10 +165,33 @@ class TrackWorker @AssistedInject constructor(
}
}
}
}.onEach {
when (it) {
is MangaUpdates.Failure -> {
val e = it.error
if (e is CloudFlareProtectedException) {
CaptchaNotifier(applicationContext).notify(e)
}
}
is MangaUpdates.Success -> {
if (it.isValid && it.isNotEmpty()) {
showNotification(
manga = it.manga,
channelId = it.channelId,
newChapters = it.newChapters,
)
}
}
}
}.toList(ArrayList(tracks.size))
}
private suspend fun showNotification(manga: Manga, channelId: String?, newChapters: List<MangaChapter>) {
private suspend fun showNotification(
manga: Manga,
channelId: String?,
newChapters: List<MangaChapter>,
) {
if (newChapters.isEmpty() || channelId == null || !applicationContext.checkNotificationPermission()) {
return
}
@@ -239,7 +256,10 @@ class TrackWorker @AssistedInject constructor(
override suspend fun getForegroundInfo(): ForegroundInfo {
val title = applicationContext.getString(R.string.check_for_new_chapters)
val channel = NotificationChannelCompat.Builder(WORKER_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
val channel = NotificationChannelCompat.Builder(
WORKER_CHANNEL_ID,
NotificationManagerCompat.IMPORTANCE_LOW
)
.setName(title)
.setShowBadge(false)
.setVibrationEnabled(false)
@@ -260,7 +280,11 @@ class TrackWorker @AssistedInject constructor(
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
.build()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(WORKER_NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
ForegroundInfo(
WORKER_NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else {
ForegroundInfo(WORKER_NOTIFICATION_ID, notification)
}
@@ -320,7 +344,8 @@ class TrackWorker @AssistedInject constructor(
}
fun startNow() {
val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
val constraints =
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
val request = OneTimeWorkRequestBuilder<TrackWorker>()
.setConstraints(constraints)
.addTag(TAG_ONESHOT)

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?><!-- drawable/bookmark.xml -->
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M17,3H7A2,2 0 0,0 5,5V21L12,18L19,21V5C19,3.89 18.1,3 17,3Z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/normal"
android:drawable="@drawable/ic_bookmark"
android:state_checked="false" />
<item
android:id="@+id/checked"
android:drawable="@drawable/ic_bookmark_checked"
android:state_checked="true" />
</selector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?><!-- drawable/sd.xml -->
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M18,8H16V4H18M15,8H13V4H15M12,8H10V4H12M18,2H10L4,8V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V4A2,2 0 0,0 18,2Z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/normal"
android:drawable="@drawable/ic_storage"
android:state_checked="false" />
<item
android:id="@+id/checked"
android:drawable="@drawable/ic_storage_checked"
android:state_checked="true" />
</selector>

View File

@@ -9,4 +9,4 @@
<path
android:fillColor="#000"
android:pathData="M12 2a7 7 0 0 1 7 7c0 2.38-1.19 4.47-3 5.74V17a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 0 1 7-7M9 21v-1h6v1a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1m3-17a5 5 0 0 0-5 5c0 2.05 1.23 3.81 3 4.58V16h4v-2.42c1.77-0.77 3-2.53 3-4.58a5 5 0 0 0-5-5z" />
</vector>
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?><!-- drawable/lightbulb.xml -->
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M12,2A7,7 0 0,0 5,9C5,11.38 6.19,13.47 8,14.74V17A1,1 0 0,0 9,18H15A1,1 0 0,0 16,17V14.74C17.81,13.47 19,11.38 19,9A7,7 0 0,0 12,2M9,21A1,1 0 0,0 10,22H14A1,1 0 0,0 15,21V20H9V21Z" />
</vector>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/normal"
android:drawable="@drawable/ic_suggestion"
android:state_checked="false" />
<item
android:id="@+id/checked"
android:drawable="@drawable/ic_suggestion_checked"
android:state_checked="true" />
</selector>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:drawablePadding="?listPreferredItemPaddingStart"
android:ellipsize="end"
android:gravity="center_vertical"
android:minHeight="?android:listPreferredItemHeightSmall"
android:orientation="horizontal"
android:paddingVertical="@dimen/margin_small"
android:paddingStart="?listPreferredItemPaddingStart"
android:paddingEnd="?listPreferredItemPaddingEnd"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodyLarge"
tools:drawableStartCompat="@drawable/ic_feed"
tools:text="@string/feed" />

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:windowBackground"
android:baselineAligned="false"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingVertical="@dimen/margin_small"
android:paddingStart="?listPreferredItemPaddingStart"
android:paddingEnd="?listPreferredItemPaddingEnd">
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:drawablePadding="?listPreferredItemPaddingStart"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodyLarge"
tools:drawableStart="@drawable/ic_explore_selector"
tools:text="@string/explore" />
<ImageView
android:id="@+id/imageView_remove"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/remove"
android:padding="@dimen/margin_small"
android:scaleType="center"
android:src="@drawable/ic_delete" />
<ImageView
android:id="@+id/imageView_reorder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/reorder"
android:padding="@dimen/margin_small"
android:scaleType="center"
android:src="@drawable/ic_reorder_handle" />
</LinearLayout>

View File

@@ -52,14 +52,15 @@
</LinearLayout>
<ImageView
android:id="@+id/imageView_config"
android:id="@+id/imageView_menu"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/settings"
android:contentDescription="@string/more"
android:padding="@dimen/margin_small"
android:scaleType="center"
android:src="@drawable/ic_settings" />
android:src="@drawable/abc_ic_menu_overflow_material"
app:tint="?colorControlNormal" />
<ImageView
android:id="@+id/imageView_add"

View File

@@ -14,7 +14,8 @@
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:minHeight="240dp">
<org.koitharu.kotatsu.core.ui.list.fastscroll.FastScrollRecyclerView
android:id="@+id/recyclerView"
@@ -29,6 +30,19 @@
tools:listitem="@layout/item_page_thumb"
tools:targetApi="m" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:indeterminate="true"
android:visibility="gone"
app:hideAnimationBehavior="outward"
app:showAnimationBehavior="inward"
app:trackCornerRadius="0dp"
app:trackThickness="2dp"
tools:visibility="visible" />
</FrameLayout>
</LinearLayout>

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/nav_history"
android:icon="@drawable/ic_history_selector"
android:title="@string/history" />
<item
android:id="@+id/nav_favourites"
android:icon="@drawable/ic_favourites_selector"
android:title="@string/favourites" />
<item
android:id="@+id/nav_explore"
android:icon="@drawable/ic_explore_selector"
android:title="@string/explore" />
<item
android:id="@+id/nav_feed"
android:icon="@drawable/ic_feed_selector"
android:title="@string/feed" />
</menu>

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/nav_favourites"
android:icon="@drawable/ic_favourites_selector"
android:title="@string/favourites" />
<item
android:id="@+id/nav_history"
android:icon="@drawable/ic_history_selector"
android:title="@string/history" />
<item
android:id="@+id/nav_explore"
android:icon="@drawable/ic_explore_selector"
android:title="@string/explore" />
<item
android:id="@+id/nav_feed"
android:icon="@drawable/ic_feed_selector"
android:title="@string/feed" />
</menu>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_retry"
android:title="@string/try_again"
app:showAsAction="never" />
</menu>

View File

@@ -10,9 +10,9 @@
app:showAsAction="never" />
<item
android:id="@+id/action_settings"
android:orderInCategory="100"
android:title="@string/settings"
android:id="@+id/action_directories"
android:orderInCategory="96"
android:title="@string/directories"
app:showAsAction="never" />
</menu>

View File

@@ -16,11 +16,6 @@
android:title="@string/disable_nsfw"
app:showAsAction="never" />
<item
android:id="@+id/action_locales"
android:title="@string/languages"
app:showAsAction="never" />
<item
android:id="@+id/action_disable_all"
android:title="@string/disable_all"

View File

@@ -10,9 +10,9 @@
app:showAsAction="never" />
<item
android:id="@+id/action_settings"
android:id="@+id/action_settings_suggestions"
android:orderInCategory="90"
android:title="@string/settings"
app:showAsAction="never" />
</menu>
</menu>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_lift"
android:title="@string/to_top" />
<item
android:id="@+id/action_settings"
android:title="@string/settings" />
</menu>

View File

@@ -471,4 +471,5 @@
<string name="related_manga_summary">Паказаць спіс звязанай мангі. У некаторых выпадках ён можа быць недакладным або адсутнічаць</string>
<string name="advanced">Пашыраныя</string>
<string name="default_section">Раздзел па змаўчанні</string>
<string name="manga_list">Спіс мангі</string>
</resources>

View File

@@ -330,7 +330,7 @@
<string name="reader_control_ltr_summary">Нажатие на правый край или нажатие правой клавиши всегда переключает на следующую страницу</string>
<string name="reader_control_ltr">Эргономичное управление режимом чтения</string>
<string name="reset">Сбросить</string>
<string name="discard">Отменить</string>
<string name="discard">Отклонить</string>
<string name="text_unsaved_changes_prompt">Сохранить или отменить несохранённые изменения\?</string>
<string name="contrast">Контрастность</string>
<string name="brightness">Яркость</string>

View File

@@ -114,7 +114,7 @@
<string name="computing_">กำลังคำนวน…</string>
<string name="create_shortcut">สร้าง shortcut…</string>
<string name="sort_order">เรียวตามลำดับ</string>
<string name="enable">เปิดการใช้งาน</string>
<string name="enable">เปิดใช้งาน</string>
<string name="downloads_removed">การดาวน์โหลดชองคุณได้ถูกลบแล้ว</string>
<string name="downloads_cancelled">การดาวน์โหลดของคุณได้ถูกยกเลิกแล้ว</string>
<string name="downloaded">ดาวน์โหลดแล้ว</string>
@@ -215,7 +215,7 @@
<string name="screenshots_block_nsfw">บล็อกบน NSFW</string>
<string name="screenshots_block_all">บล็อกตลอด</string>
<string name="suggestions">คําแนะนํา</string>
<string name="suggestions_enable">เปิดใช้งานคำแนะนำ</string>
<string name="suggestions_enable">เปิดใช้คำแนะนำ</string>
<string name="manga_shelf">ชั้น</string>
<string name="not_available">ไม่สามารถใช้ได้</string>
<string name="backup_information">คุณสามารถสํารองข้อมูลประวัติและรายการโปรดและกู้คืน</string>
@@ -231,4 +231,42 @@
<string name="about_app_translation">การแปล</string>
<string name="auth_complete">ได้รับอนุญาต</string>
<string name="default_mode">โหมดเริ่มต้น</string>
<string name="new_sources_text">มีแหล่งมังงะใหม่ให้เลือก</string>
<string name="crash_text">มีบางอย่างผิดพลาด. กรุณารายงานข้อผิดพลาดไปยังนักพัฒนาเพื่อแก้ไข</string>
<string name="reader_mode_hint">การตั้งค่าที่ตั้งไว้จะถูกบันทึกไว้สำหรับมังงะเรื่องนี้</string>
<string name="suggestions_summary">แนะนำมังงะตามความต้องการของคุณ</string>
<string name="text_suggestion_holder">เริ่มอ่านมังงะแล้วคุณจะได้รับการแนะนำมังงะตามความชอบของคุณ</string>
<string name="exclude_nsfw_from_suggestions">อย่าแนะนำมังงะ NSFW</string>
<string name="reset_filter">รีเซ็ตการกรอง</string>
<string name="onboard_text">เลือกภาษาที่คุณต้องการอ่านมังงะ คุณสามารถเปลี่ยนได้ในภายหลังในการตั้งค่า</string>
<string name="never">ไม่เคย</string>
<string name="only_using_wifi">ใช้ Wi-Fi เท่านั้น</string>
<string name="always">ตลอด</string>
<string name="preload_pages">โหลดหน้าล่วงหน้า</string>
<string name="nsfw">18+</string>
<string name="percent_string_pattern">%1$s%%</string>
<string name="text_delete_local_manga_batch">ลบรายการที่เลือกออกจากอุปกรณ์อย่างถาวรไหม\?</string>
<string name="removal_completed">การลบเสร็จสมบูรณ์</string>
<string name="download_slowdown_summary">ช่วยหลีกเลี่ยงการบล็อก IP ของคุณ</string>
<string name="canceled">ยกเลิก</string>
<string name="account_already_exists">บัญชีนี้มีอยู่แล้ว</string>
<string name="back">กลับ</string>
<string name="enabled">เปิดใช้งานแล้ว</string>
<string name="disabled">ปิดการใช้งานแล้ว</string>
<string name="sync_title">ซิงค์ข้อมูลของคุณ</string>
<string name="email_enter_hint">กรอกอีเมลของคุณเพื่อดำเนินการต่อ</string>
<string name="hide">ซ่อน</string>
<string name="notifications_enable">เปิดการแจ้งเตือน</string>
<string name="name">ชื่อ</string>
<string name="edit">แก้ไข</string>
<string name="undo">ย้อนกลับ</string>
<string name="disable_battery_optimization">ปิดใช้งานการเพิ่มประสิทธิภาพแบตเตอรี่</string>
<string name="disable_battery_optimization_summary">ช่วยในการตรวจสอบการอัปเดตพื้นหลัง</string>
<string name="send">ส่ง</string>
<string name="status_reading">กำลังอ่าน</string>
<string name="status_re_reading">อ่านซ้ำ</string>
<string name="status_completed">อ่านเสร็จแล้ว</string>
<string name="status_on_hold">พักไว้</string>
<string name="disable_all">ปิดการใช้งานทั้งหมด</string>
<string name="report">รีพอร์ต</string>
</resources>

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