Merge remote-tracking branch 'origin/devel' into devel
commit
7c74c87524
@ -1,198 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.tracker.domain
|
|
||||||
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import dagger.hilt.android.testing.HiltAndroidRule
|
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
|
||||||
import junit.framework.TestCase.*
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.koitharu.kotatsu.SampleData
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltAndroidTest
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class TrackerTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
var hiltRule = HiltAndroidRule(this)
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var repository: TrackingRepository
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var dataRepository: MangaDataRepository
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var tracker: Tracker
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
hiltRule.inject()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun noUpdates() = runTest {
|
|
||||||
val manga = loadManga("full.json")
|
|
||||||
tracker.deleteTrack(manga.id)
|
|
||||||
|
|
||||||
tracker.checkUpdates(manga, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(manga.id))
|
|
||||||
tracker.checkUpdates(manga, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(manga.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun hasUpdates() = runTest {
|
|
||||||
val mangaFirst = loadManga("first_chapters.json")
|
|
||||||
val mangaFull = loadManga("full.json")
|
|
||||||
tracker.deleteTrack(mangaFirst.id)
|
|
||||||
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assertEquals(3, newChapters.size)
|
|
||||||
}
|
|
||||||
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun badIds() = runTest {
|
|
||||||
val mangaFirst = loadManga("first_chapters.json")
|
|
||||||
val mangaBad = loadManga("bad_ids.json")
|
|
||||||
tracker.deleteTrack(mangaFirst.id)
|
|
||||||
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaBad, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun badIds2() = runTest {
|
|
||||||
val mangaFirst = loadManga("first_chapters.json")
|
|
||||||
val mangaBad = loadManga("bad_ids.json")
|
|
||||||
val mangaFull = loadManga("full.json")
|
|
||||||
tracker.deleteTrack(mangaFirst.id)
|
|
||||||
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assertEquals(3, newChapters.size)
|
|
||||||
}
|
|
||||||
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaBad, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fullReset() = runTest {
|
|
||||||
val mangaFull = loadManga("full.json")
|
|
||||||
val mangaFirst = loadManga("first_chapters.json")
|
|
||||||
val mangaEmpty = loadManga("empty.json")
|
|
||||||
tracker.deleteTrack(mangaFull.id)
|
|
||||||
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaEmpty, commit = true).apply {
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assertEquals(3, newChapters.size)
|
|
||||||
}
|
|
||||||
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaEmpty, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun syncWithHistory() = runTest {
|
|
||||||
val mangaFull = loadManga("full.json")
|
|
||||||
val mangaFirst = loadManga("first_chapters.json")
|
|
||||||
tracker.deleteTrack(mangaFull.id)
|
|
||||||
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assertEquals(3, newChapters.size)
|
|
||||||
}
|
|
||||||
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
|
|
||||||
var chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) }
|
|
||||||
tracker.syncWithHistory(mangaFull, chapter.id)
|
|
||||||
|
|
||||||
assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
|
|
||||||
chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex) }
|
|
||||||
tracker.syncWithHistory(mangaFull, chapter.id)
|
|
||||||
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadManga(name: String): Manga {
|
|
||||||
val manga = SampleData.loadAsset("manga/$name", Manga::class)
|
|
||||||
dataRepository.storeManga(manga)
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.google.auto.service.AutoService
|
|
||||||
import org.acra.builder.ReportBuilder
|
|
||||||
import org.acra.config.CoreConfiguration
|
|
||||||
import org.acra.config.ReportingAdministrator
|
|
||||||
|
|
||||||
@AutoService(ReportingAdministrator::class)
|
|
||||||
class ErrorReportingAdmin : ReportingAdministrator {
|
|
||||||
|
|
||||||
override fun shouldStartCollecting(
|
|
||||||
context: Context,
|
|
||||||
config: CoreConfiguration,
|
|
||||||
reportBuilder: ReportBuilder
|
|
||||||
): Boolean {
|
|
||||||
return reportBuilder.exception?.isDeadOs() != true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Throwable.isDeadOs(): Boolean {
|
|
||||||
val className = javaClass.simpleName
|
|
||||||
return className == "DeadSystemException" || className == "DeadSystemRuntimeException" || cause?.isDeadOs() == true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db
|
||||||
|
|
||||||
|
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||||
|
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||||
|
import java.util.LinkedList
|
||||||
|
|
||||||
|
class MangaQueryBuilder(
|
||||||
|
private val table: String,
|
||||||
|
private val conditionCallback: ConditionCallback
|
||||||
|
) {
|
||||||
|
|
||||||
|
private var filterOptions: Collection<ListFilterOption> = emptyList()
|
||||||
|
private var whereConditions = LinkedList<String>()
|
||||||
|
private var orderBy: String? = null
|
||||||
|
private var groupBy: String? = null
|
||||||
|
private var extraJoins: String? = null
|
||||||
|
private var limit: Int = 0
|
||||||
|
|
||||||
|
fun filters(options: Collection<ListFilterOption>) = apply {
|
||||||
|
filterOptions = options
|
||||||
|
}
|
||||||
|
|
||||||
|
fun where(condition: String) = apply {
|
||||||
|
whereConditions.add(condition)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun orderBy(orderBy: String?) = apply {
|
||||||
|
this@MangaQueryBuilder.orderBy = orderBy
|
||||||
|
}
|
||||||
|
|
||||||
|
fun groupBy(groupBy: String?) = apply {
|
||||||
|
this@MangaQueryBuilder.groupBy = groupBy
|
||||||
|
}
|
||||||
|
|
||||||
|
fun limit(limit: Int) = apply {
|
||||||
|
this@MangaQueryBuilder.limit = limit
|
||||||
|
}
|
||||||
|
|
||||||
|
fun join(join: String?) = apply {
|
||||||
|
extraJoins = join
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build() = buildString {
|
||||||
|
append("SELECT * FROM ")
|
||||||
|
append(table)
|
||||||
|
extraJoins?.let {
|
||||||
|
append(' ')
|
||||||
|
append(it)
|
||||||
|
}
|
||||||
|
if (whereConditions.isNotEmpty()) {
|
||||||
|
whereConditions.joinTo(
|
||||||
|
buffer = this,
|
||||||
|
prefix = " WHERE ",
|
||||||
|
separator = " AND ",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (filterOptions.isNotEmpty()) {
|
||||||
|
if (whereConditions.isEmpty()) {
|
||||||
|
append(" WHERE")
|
||||||
|
} else {
|
||||||
|
append(" AND")
|
||||||
|
}
|
||||||
|
var isFirst = true
|
||||||
|
val groupedOptions = filterOptions.groupBy { it.groupKey }
|
||||||
|
for ((_, group) in groupedOptions) {
|
||||||
|
if (group.isEmpty()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (isFirst) {
|
||||||
|
isFirst = false
|
||||||
|
append(' ')
|
||||||
|
} else {
|
||||||
|
append(" AND ")
|
||||||
|
}
|
||||||
|
if (group.size > 1) {
|
||||||
|
group.joinTo(
|
||||||
|
buffer = this,
|
||||||
|
separator = " OR ",
|
||||||
|
prefix = "(",
|
||||||
|
postfix = ")",
|
||||||
|
transform = ::getConditionOrThrow,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
append(getConditionOrThrow(group.single()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groupBy?.let {
|
||||||
|
append(" GROUP BY ")
|
||||||
|
append(it)
|
||||||
|
}
|
||||||
|
orderBy?.let {
|
||||||
|
append(" ORDER BY ")
|
||||||
|
append(it)
|
||||||
|
}
|
||||||
|
if (limit > 0) {
|
||||||
|
append(" LIMIT ")
|
||||||
|
append(limit)
|
||||||
|
}
|
||||||
|
}.let { SimpleSQLiteQuery(it) }
|
||||||
|
|
||||||
|
private fun getConditionOrThrow(option: ListFilterOption): String = when (option) {
|
||||||
|
is ListFilterOption.Inverted -> "NOT(${getConditionOrThrow(option.option)})"
|
||||||
|
else -> requireNotNull(conditionCallback.getCondition(option)) {
|
||||||
|
"Unsupported filter option $option"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interface ConditionCallback {
|
||||||
|
|
||||||
|
fun getCondition(option: ListFilterOption): String?
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import java.net.ProtocolException
|
||||||
|
|
||||||
|
class ProxyConfigException : ProtocolException("Wrong proxy configuration")
|
||||||
@ -1,13 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions
|
|
||||||
|
|
||||||
import okio.IOException
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.temporal.ChronoUnit
|
|
||||||
|
|
||||||
class TooManyRequestExceptions(
|
|
||||||
val url: String,
|
|
||||||
val retryAt: Instant?,
|
|
||||||
) : IOException() {
|
|
||||||
val retryAfter: Long
|
|
||||||
get() = retryAt?.until(Instant.now(), ChronoUnit.MILLIS) ?: 0
|
|
||||||
}
|
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package org.koitharu.kotatsu.core.model
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
|
||||||
|
enum class GenericSortOrder(
|
||||||
|
@StringRes val titleResId: Int,
|
||||||
|
val ascending: SortOrder,
|
||||||
|
val descending: SortOrder,
|
||||||
|
) {
|
||||||
|
|
||||||
|
UPDATED(R.string.updated, SortOrder.UPDATED_ASC, SortOrder.UPDATED),
|
||||||
|
RATING(R.string.by_rating, SortOrder.RATING_ASC, SortOrder.RATING),
|
||||||
|
POPULARITY(R.string.popularity, SortOrder.POPULARITY_ASC, SortOrder.POPULARITY),
|
||||||
|
DATE(R.string.by_date, SortOrder.NEWEST_ASC, SortOrder.NEWEST),
|
||||||
|
NAME(R.string.by_name, SortOrder.ALPHABETICAL, SortOrder.ALPHABETICAL_DESC),
|
||||||
|
;
|
||||||
|
|
||||||
|
operator fun get(direction: SortDirection): SortOrder = when (direction) {
|
||||||
|
SortDirection.ASC -> ascending
|
||||||
|
SortDirection.DESC -> descending
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun of(order: SortOrder): GenericSortOrder = entries.first { e ->
|
||||||
|
e.ascending == order || e.descending == order
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package org.koitharu.kotatsu.core.model
|
||||||
|
|
||||||
|
enum class SortDirection {
|
||||||
|
|
||||||
|
ASC, DESC;
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui
|
||||||
|
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface BaseActivityEntryPoint {
|
||||||
|
|
||||||
|
val settings: AppSettings
|
||||||
|
|
||||||
|
val exceptionResolverFactory: ExceptionResolver.Factory
|
||||||
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.dialog
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.widget.CompoundButton.OnCheckedChangeListener
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.AdapterDelegatesManager
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
inline fun buildAlertDialog(
|
||||||
|
context: Context,
|
||||||
|
isCentered: Boolean = false,
|
||||||
|
block: MaterialAlertDialogBuilder.() -> Unit,
|
||||||
|
): AlertDialog = MaterialAlertDialogBuilder(
|
||||||
|
context,
|
||||||
|
if (isCentered) materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered else 0,
|
||||||
|
).apply(block).create()
|
||||||
|
|
||||||
|
fun <B : AlertDialog.Builder> B.setCheckbox(
|
||||||
|
@StringRes textResId: Int,
|
||||||
|
isChecked: Boolean,
|
||||||
|
onCheckedChangeListener: OnCheckedChangeListener
|
||||||
|
) = apply {
|
||||||
|
val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
|
||||||
|
binding.checkbox.setText(textResId)
|
||||||
|
binding.checkbox.isChecked = isChecked
|
||||||
|
binding.checkbox.setOnCheckedChangeListener(onCheckedChangeListener)
|
||||||
|
setView(binding.root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
|
||||||
|
list: List<T>,
|
||||||
|
delegate: AdapterDelegate<List<T>>,
|
||||||
|
) = apply {
|
||||||
|
val delegatesManager = AdapterDelegatesManager<List<T>>()
|
||||||
|
delegatesManager.addDelegate(delegate)
|
||||||
|
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
|
||||||
|
list: List<T>,
|
||||||
|
vararg delegates: AdapterDelegate<List<T>>,
|
||||||
|
) = apply {
|
||||||
|
val delegatesManager = AdapterDelegatesManager<List<T>>()
|
||||||
|
delegates.forEach { delegatesManager.addDelegate(it) }
|
||||||
|
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <B : AlertDialog.Builder> B.setRecyclerViewList(adapter: RecyclerView.Adapter<*>) = apply {
|
||||||
|
val recyclerView = RecyclerView(context)
|
||||||
|
recyclerView.layoutManager = LinearLayoutManager(context)
|
||||||
|
recyclerView.updatePadding(
|
||||||
|
top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing),
|
||||||
|
)
|
||||||
|
recyclerView.clipToPadding = false
|
||||||
|
recyclerView.adapter = adapter
|
||||||
|
setView(recyclerView)
|
||||||
|
}
|
||||||
@ -1,80 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.dialog
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.DialogInterface
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import androidx.annotation.DrawableRes
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
|
|
||||||
|
|
||||||
class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog) :
|
|
||||||
DialogInterface by delegate {
|
|
||||||
|
|
||||||
fun show() = delegate.show()
|
|
||||||
|
|
||||||
class Builder(context: Context) {
|
|
||||||
|
|
||||||
private val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
|
|
||||||
|
|
||||||
private val delegate = MaterialAlertDialogBuilder(context)
|
|
||||||
.setView(binding.root)
|
|
||||||
|
|
||||||
fun setTitle(@StringRes titleResId: Int): Builder {
|
|
||||||
delegate.setTitle(titleResId)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setTitle(title: CharSequence): Builder {
|
|
||||||
delegate.setTitle(title)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setMessage(@StringRes messageId: Int): Builder {
|
|
||||||
delegate.setMessage(messageId)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setMessage(message: CharSequence): Builder {
|
|
||||||
delegate.setMessage(message)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setCheckBoxText(@StringRes textId: Int): Builder {
|
|
||||||
binding.checkbox.setText(textId)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setCheckBoxChecked(isChecked: Boolean): Builder {
|
|
||||||
binding.checkbox.isChecked = isChecked
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setIcon(@DrawableRes iconId: Int): Builder {
|
|
||||||
delegate.setIcon(iconId)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPositiveButton(
|
|
||||||
@StringRes textId: Int,
|
|
||||||
listener: (DialogInterface, Boolean) -> Unit
|
|
||||||
): Builder {
|
|
||||||
delegate.setPositiveButton(textId) { dialog, _ ->
|
|
||||||
listener(dialog, binding.checkbox.isChecked)
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setNegativeButton(
|
|
||||||
@StringRes textId: Int,
|
|
||||||
listener: DialogInterface.OnClickListener? = null
|
|
||||||
): Builder {
|
|
||||||
delegate.setNegativeButton(textId, listener)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun create() = CheckBoxAlertDialog(delegate.create())
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.dialog
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.DialogInterface
|
|
||||||
import androidx.annotation.DrawableRes
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.AdapterDelegatesManager
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
|
|
||||||
class RecyclerViewAlertDialog private constructor(
|
|
||||||
private val delegate: AlertDialog
|
|
||||||
) : DialogInterface by delegate {
|
|
||||||
|
|
||||||
fun show() = delegate.show()
|
|
||||||
|
|
||||||
class Builder<T>(context: Context) {
|
|
||||||
|
|
||||||
private val recyclerView = RecyclerView(context)
|
|
||||||
private val delegatesManager = AdapterDelegatesManager<List<T>>()
|
|
||||||
private var items: List<T>? = null
|
|
||||||
|
|
||||||
private val delegate = MaterialAlertDialogBuilder(context)
|
|
||||||
.setView(recyclerView)
|
|
||||||
|
|
||||||
init {
|
|
||||||
recyclerView.layoutManager = LinearLayoutManager(context)
|
|
||||||
recyclerView.updatePadding(
|
|
||||||
top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing),
|
|
||||||
)
|
|
||||||
recyclerView.clipToPadding = false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setTitle(@StringRes titleResId: Int): Builder<T> {
|
|
||||||
delegate.setTitle(titleResId)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setTitle(title: CharSequence): Builder<T> {
|
|
||||||
delegate.setTitle(title)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setIcon(@DrawableRes iconId: Int): Builder<T> {
|
|
||||||
delegate.setIcon(iconId)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPositiveButton(
|
|
||||||
@StringRes textId: Int,
|
|
||||||
listener: DialogInterface.OnClickListener,
|
|
||||||
): Builder<T> {
|
|
||||||
delegate.setPositiveButton(textId, listener)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setNegativeButton(
|
|
||||||
@StringRes textId: Int,
|
|
||||||
listener: DialogInterface.OnClickListener? = null
|
|
||||||
): Builder<T> {
|
|
||||||
delegate.setNegativeButton(textId, listener)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setNeutralButton(
|
|
||||||
@StringRes textId: Int,
|
|
||||||
listener: DialogInterface.OnClickListener,
|
|
||||||
): Builder<T> {
|
|
||||||
delegate.setNeutralButton(textId, listener)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setCancelable(isCancelable: Boolean): Builder<T> {
|
|
||||||
delegate.setCancelable(isCancelable)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addAdapterDelegate(subject: AdapterDelegate<List<T>>): Builder<T> {
|
|
||||||
delegatesManager.addDelegate(subject)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setItems(list: List<T>): Builder<T> {
|
|
||||||
items = list
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun create(): RecyclerViewAlertDialog {
|
|
||||||
recyclerView.adapter = ListDelegationAdapter(delegatesManager).also {
|
|
||||||
it.items = items
|
|
||||||
}
|
|
||||||
return RecyclerViewAlertDialog(delegate.create())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.dialog
|
||||||
|
|
||||||
|
import android.widget.CompoundButton
|
||||||
|
import android.widget.CompoundButton.OnCheckedChangeListener
|
||||||
|
|
||||||
|
class RememberCheckListener(
|
||||||
|
initialValue: Boolean,
|
||||||
|
) : OnCheckedChangeListener {
|
||||||
|
|
||||||
|
var isChecked: Boolean = initialValue
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
|
||||||
|
this.isChecked = isChecked
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.list
|
||||||
|
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
||||||
|
abstract class BaseListSelectionCallback(
|
||||||
|
protected val recyclerView: RecyclerView,
|
||||||
|
) : ListSelectionController.Callback {
|
||||||
|
|
||||||
|
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
|
||||||
|
recyclerView.invalidateItemDecorations()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.util
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import com.google.android.material.appbar.AppBarLayout
|
||||||
|
|
||||||
|
class FadingAppbarMediator(
|
||||||
|
private val appBarLayout: AppBarLayout,
|
||||||
|
private val target: View
|
||||||
|
) : AppBarLayout.OnOffsetChangedListener {
|
||||||
|
|
||||||
|
private var isBound: Boolean = false
|
||||||
|
|
||||||
|
fun bind() {
|
||||||
|
if (!isBound) {
|
||||||
|
appBarLayout.addOnOffsetChangedListener(this)
|
||||||
|
isBound = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unbind() {
|
||||||
|
if (isBound) {
|
||||||
|
appBarLayout.removeOnOffsetChangedListener(this)
|
||||||
|
isBound = false
|
||||||
|
}
|
||||||
|
target.alpha = 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOffsetChanged(appBarLayout: AppBarLayout?, verticalOffset: Int) {
|
||||||
|
val scrollRange = (appBarLayout ?: return).totalScrollRange
|
||||||
|
if (scrollRange <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target.alpha = 1f + verticalOffset / (scrollRange / 2f)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.util
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
|
|
||||||
class TaggedActivityResult(
|
|
||||||
val tag: String,
|
|
||||||
val result: Int,
|
|
||||||
) {
|
|
||||||
|
|
||||||
val isSuccess: Boolean
|
|
||||||
get() = result == Activity.RESULT_OK
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.util.ext
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
|
|
||||||
|
|
||||||
fun BottomSheetBehavior<*>.doOnExpansionsChanged(callback: (isExpanded: Boolean) -> Unit) {
|
|
||||||
var isExpended = state == BottomSheetBehavior.STATE_EXPANDED
|
|
||||||
callback(isExpended)
|
|
||||||
addBottomSheetCallback(
|
|
||||||
object : BottomSheetCallback() {
|
|
||||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
|
||||||
val expanded = newState == BottomSheetBehavior.STATE_EXPANDED
|
|
||||||
if (expanded != isExpended) {
|
|
||||||
isExpended = expanded
|
|
||||||
callback(expanded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util.progress
|
||||||
|
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.annotation.AnyThread
|
||||||
|
import androidx.collection.CircularArray
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
|
||||||
|
class RealtimeEtaEstimator {
|
||||||
|
|
||||||
|
private val ticks = CircularArray<Tick>(MAX_TICKS)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var lastChange = 0L
|
||||||
|
|
||||||
|
@AnyThread
|
||||||
|
fun onProgressChanged(value: Int, total: Int) {
|
||||||
|
if (total <= 0 || value > total) {
|
||||||
|
reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val tick = Tick(value, total, SystemClock.elapsedRealtime())
|
||||||
|
synchronized(this) {
|
||||||
|
if (!ticks.isEmpty()) {
|
||||||
|
val last = ticks.last
|
||||||
|
if (last.value == tick.value && last.total == tick.total) {
|
||||||
|
ticks.popLast()
|
||||||
|
} else {
|
||||||
|
lastChange = tick.timestamp
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lastChange = tick.timestamp
|
||||||
|
}
|
||||||
|
ticks.addLast(tick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AnyThread
|
||||||
|
fun reset() = synchronized(this) {
|
||||||
|
ticks.clear()
|
||||||
|
lastChange = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
@AnyThread
|
||||||
|
fun getEta(): Long {
|
||||||
|
val etl = getEstimatedTimeLeft()
|
||||||
|
return if (etl == NO_TIME || etl > MAX_TIME) NO_TIME else System.currentTimeMillis() + etl
|
||||||
|
}
|
||||||
|
|
||||||
|
@AnyThread
|
||||||
|
fun isStuck(): Boolean = synchronized(this) {
|
||||||
|
return ticks.size() >= MIN_ESTIMATE_TICKS && (SystemClock.elapsedRealtime() - lastChange) > STUCK_DELAY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getEstimatedTimeLeft(): Long = synchronized(this) {
|
||||||
|
val ticksCount = ticks.size()
|
||||||
|
if (ticksCount < MIN_ESTIMATE_TICKS) {
|
||||||
|
return NO_TIME
|
||||||
|
}
|
||||||
|
val percentDiff = ticks.last.percent - ticks.first.percent
|
||||||
|
val timeDiff = ticks.last.timestamp - ticks.first.timestamp
|
||||||
|
if (percentDiff <= 0 || timeDiff <= 0) {
|
||||||
|
return NO_TIME
|
||||||
|
}
|
||||||
|
val averageTime = timeDiff / percentDiff
|
||||||
|
val percentLeft = 1.0 - ticks.last.percent
|
||||||
|
return (percentLeft * averageTime).roundToLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Tick(
|
||||||
|
@JvmField val value: Int,
|
||||||
|
@JvmField val total: Int,
|
||||||
|
@JvmField val timestamp: Long,
|
||||||
|
) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(total > 0) { "total = $total" }
|
||||||
|
require(value >= 0) { "value = $value" }
|
||||||
|
require(value <= total) { "total = $total, value = $value" }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
val percent = value.toDouble() / total.toDouble()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
const val MAX_TICKS = 20
|
||||||
|
const val MIN_ESTIMATE_TICKS = 4
|
||||||
|
const val NO_TIME = -1L
|
||||||
|
const val STUCK_DELAY = 10_000L
|
||||||
|
val MAX_TIME = TimeUnit.DAYS.toMillis(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,69 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.util.progress
|
|
||||||
|
|
||||||
import android.os.SystemClock
|
|
||||||
import androidx.collection.IntList
|
|
||||||
import androidx.collection.MutableIntList
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import kotlin.math.roundToLong
|
|
||||||
|
|
||||||
private const val MIN_ESTIMATE_TICKS = 4
|
|
||||||
private const val NO_TIME = -1L
|
|
||||||
|
|
||||||
class TimeLeftEstimator {
|
|
||||||
|
|
||||||
private var times = MutableIntList()
|
|
||||||
private var lastTick: Tick? = null
|
|
||||||
private val tooLargeTime = TimeUnit.DAYS.toMillis(1)
|
|
||||||
|
|
||||||
fun tick(value: Int, total: Int) {
|
|
||||||
if (total < 0) {
|
|
||||||
emptyTick()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (lastTick?.value == value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val tick = Tick(value, total, SystemClock.elapsedRealtime())
|
|
||||||
lastTick?.let {
|
|
||||||
val ticksCount = value - it.value
|
|
||||||
times.add(((tick.time - it.time) / ticksCount.toDouble()).roundToInt())
|
|
||||||
}
|
|
||||||
lastTick = tick
|
|
||||||
}
|
|
||||||
|
|
||||||
fun emptyTick() {
|
|
||||||
lastTick = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getEstimatedTimeLeft(): Long {
|
|
||||||
val progress = lastTick ?: return NO_TIME
|
|
||||||
if (times.size < MIN_ESTIMATE_TICKS) {
|
|
||||||
return NO_TIME
|
|
||||||
}
|
|
||||||
val timePerTick = times.average()
|
|
||||||
val ticksLeft = progress.total - progress.value
|
|
||||||
val eta = (ticksLeft * timePerTick).roundToLong()
|
|
||||||
return if (eta < tooLargeTime) eta else NO_TIME
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getEta(): Long {
|
|
||||||
val etl = getEstimatedTimeLeft()
|
|
||||||
return if (etl == NO_TIME) NO_TIME else System.currentTimeMillis() + etl
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun IntList.average(): Double {
|
|
||||||
if (isEmpty()) {
|
|
||||||
return 0.0
|
|
||||||
}
|
|
||||||
var acc = 0L
|
|
||||||
forEach { acc += it }
|
|
||||||
return acc / size.toDouble()
|
|
||||||
}
|
|
||||||
|
|
||||||
private class Tick(
|
|
||||||
@JvmField val value: Int,
|
|
||||||
@JvmField val total: Int,
|
|
||||||
@JvmField val time: Long,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -0,0 +1,233 @@
|
|||||||
|
package org.koitharu.kotatsu.details.ui.pager
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
|
import okio.FileNotFoundException
|
||||||
|
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||||
|
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.requireValue
|
||||||
|
import org.koitharu.kotatsu.details.data.MangaDetails
|
||||||
|
import org.koitharu.kotatsu.details.domain.DetailsInteractor
|
||||||
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
|
import org.koitharu.kotatsu.details.ui.DetailsViewModel
|
||||||
|
import org.koitharu.kotatsu.details.ui.mapChapters
|
||||||
|
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||||
|
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||||
|
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||||
|
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
|
||||||
|
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||||
|
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||||
|
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||||
|
|
||||||
|
abstract class ChaptersPagesViewModel(
|
||||||
|
@JvmField protected val settings: AppSettings,
|
||||||
|
private val interactor: DetailsInteractor,
|
||||||
|
private val bookmarksRepository: BookmarksRepository,
|
||||||
|
private val historyRepository: HistoryRepository,
|
||||||
|
private val downloadScheduler: DownloadWorker.Scheduler,
|
||||||
|
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
|
||||||
|
private val localStorageChanges: SharedFlow<LocalManga?>,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val mangaDetails = MutableStateFlow<MangaDetails?>(null)
|
||||||
|
val readingState = MutableStateFlow<ReaderState?>(null)
|
||||||
|
|
||||||
|
val onActionDone = MutableEventFlow<ReversibleAction>()
|
||||||
|
val onSelectChapter = MutableEventFlow<Long>()
|
||||||
|
val onDownloadStarted = MutableEventFlow<Unit>()
|
||||||
|
val onMangaRemoved = MutableEventFlow<Manga>()
|
||||||
|
|
||||||
|
private val chaptersQuery = MutableStateFlow("")
|
||||||
|
val selectedBranch = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
|
val manga = mangaDetails.map { x -> x?.toManga() }
|
||||||
|
.withErrorHandling()
|
||||||
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||||
|
|
||||||
|
val isChaptersReversed = settings.observeAsStateFlow(
|
||||||
|
scope = viewModelScope + Dispatchers.Default,
|
||||||
|
key = AppSettings.KEY_REVERSE_CHAPTERS,
|
||||||
|
valueProducer = { isChaptersReverse },
|
||||||
|
)
|
||||||
|
|
||||||
|
val isChaptersInGridView = settings.observeAsStateFlow(
|
||||||
|
scope = viewModelScope + Dispatchers.Default,
|
||||||
|
key = AppSettings.KEY_GRID_VIEW_CHAPTERS,
|
||||||
|
valueProducer = { isChaptersGridView },
|
||||||
|
)
|
||||||
|
|
||||||
|
val newChaptersCount = mangaDetails.flatMapLatest { d ->
|
||||||
|
if (d?.isLocal == false) {
|
||||||
|
interactor.observeNewChapters(d.id)
|
||||||
|
} else {
|
||||||
|
flowOf(0)
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
|
||||||
|
|
||||||
|
val isChaptersEmpty: StateFlow<Boolean> = mangaDetails.map {
|
||||||
|
it != null && it.isLoaded && it.allChapters.isEmpty()
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
|
||||||
|
|
||||||
|
val bookmarks = mangaDetails.flatMapLatest {
|
||||||
|
if (it != null) bookmarksRepository.observeBookmarks(it.toManga()) else flowOf(emptyList())
|
||||||
|
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
|
||||||
|
|
||||||
|
val chapters = combine(
|
||||||
|
combine(
|
||||||
|
mangaDetails,
|
||||||
|
readingState.map { it?.chapterId ?: 0L }.distinctUntilChanged(),
|
||||||
|
selectedBranch,
|
||||||
|
newChaptersCount,
|
||||||
|
bookmarks,
|
||||||
|
isChaptersInGridView,
|
||||||
|
) { manga, currentChapterId, branch, news, bookmarks, grid ->
|
||||||
|
manga?.mapChapters(
|
||||||
|
currentChapterId,
|
||||||
|
news,
|
||||||
|
branch,
|
||||||
|
bookmarks,
|
||||||
|
grid,
|
||||||
|
).orEmpty()
|
||||||
|
},
|
||||||
|
isChaptersReversed,
|
||||||
|
chaptersQuery,
|
||||||
|
) { list, reversed, query ->
|
||||||
|
(if (reversed) list.asReversed() else list).filterSearch(query)
|
||||||
|
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
|
init {
|
||||||
|
launchJob(Dispatchers.Default) {
|
||||||
|
localStorageChanges
|
||||||
|
.collect { onDownloadComplete(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setChaptersReversed(newValue: Boolean) {
|
||||||
|
settings.isChaptersReverse = newValue
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setChaptersInGridView(newValue: Boolean) {
|
||||||
|
settings.isChaptersGridView = newValue
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSelectedBranch(branch: String?) {
|
||||||
|
selectedBranch.value = branch
|
||||||
|
}
|
||||||
|
|
||||||
|
fun performChapterSearch(query: String?) {
|
||||||
|
chaptersQuery.value = query?.trim().orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMangaOrNull(): Manga? = mangaDetails.value?.toManga()
|
||||||
|
|
||||||
|
fun requireManga() = mangaDetails.requireValue().toManga()
|
||||||
|
|
||||||
|
fun markChapterAsCurrent(chapterId: Long) {
|
||||||
|
launchJob(Dispatchers.Default) {
|
||||||
|
val manga = mangaDetails.requireValue()
|
||||||
|
val chapters = checkNotNull(manga.chapters[selectedBranch.value])
|
||||||
|
val chapterIndex = chapters.indexOfFirst { it.id == chapterId }
|
||||||
|
check(chapterIndex in chapters.indices) { "Chapter not found" }
|
||||||
|
val percent = chapterIndex / chapters.size.toFloat()
|
||||||
|
historyRepository.addOrUpdate(
|
||||||
|
manga = manga.toManga(),
|
||||||
|
chapterId = chapterId,
|
||||||
|
page = 0,
|
||||||
|
scroll = 0,
|
||||||
|
percent = percent,
|
||||||
|
force = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun download(chaptersIds: Set<Long>?) {
|
||||||
|
launchJob(Dispatchers.Default) {
|
||||||
|
downloadScheduler.schedule(
|
||||||
|
requireManga(),
|
||||||
|
chaptersIds,
|
||||||
|
)
|
||||||
|
onDownloadStarted.call(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteLocal() {
|
||||||
|
val m = mangaDetails.value?.local?.manga
|
||||||
|
if (m == null) {
|
||||||
|
errorEvent.call(FileNotFoundException())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
|
deleteLocalMangaUseCase(m)
|
||||||
|
onMangaRemoved.call(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> {
|
||||||
|
if (query.isEmpty() || this.isEmpty()) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
return filter {
|
||||||
|
it.chapter.name.contains(query, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun onDownloadComplete(downloadedManga: LocalManga?) {
|
||||||
|
downloadedManga ?: return
|
||||||
|
mangaDetails.update {
|
||||||
|
interactor.updateLocal(it, downloadedManga)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ActivityVMLazy(
|
||||||
|
private val fragment: Fragment,
|
||||||
|
) : Lazy<ChaptersPagesViewModel> {
|
||||||
|
private var cached: ChaptersPagesViewModel? = null
|
||||||
|
|
||||||
|
override val value: ChaptersPagesViewModel
|
||||||
|
get() {
|
||||||
|
val viewModel = cached
|
||||||
|
return if (viewModel == null) {
|
||||||
|
val activity = fragment.requireActivity()
|
||||||
|
val vmClass = getViewModelClass(activity)
|
||||||
|
ViewModelProvider.create(
|
||||||
|
store = activity.viewModelStore,
|
||||||
|
factory = activity.defaultViewModelProviderFactory,
|
||||||
|
extras = activity.defaultViewModelCreationExtras,
|
||||||
|
)[vmClass].also { cached = it }
|
||||||
|
} else {
|
||||||
|
viewModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isInitialized(): Boolean = cached != null
|
||||||
|
|
||||||
|
private fun getViewModelClass(activity: Activity) = when (activity) {
|
||||||
|
is ReaderActivity -> ReaderViewModel::class.java
|
||||||
|
is DetailsActivity -> DetailsViewModel::class.java
|
||||||
|
else -> error("Wrong activity ${activity.javaClass.simpleName} for ${ChaptersPagesViewModel::class.java.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
package org.koitharu.kotatsu.details.ui.pager.chapters
|
||||||
|
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuItem
|
||||||
|
import androidx.appcompat.view.ActionMode
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.BaseListSelectionCallback
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toCollection
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toSet
|
||||||
|
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
|
||||||
|
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
|
||||||
|
|
||||||
|
class ChaptersSelectionCallback(
|
||||||
|
private val viewModel: ChaptersPagesViewModel,
|
||||||
|
recyclerView: RecyclerView,
|
||||||
|
) : BaseListSelectionCallback(recyclerView) {
|
||||||
|
|
||||||
|
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||||
|
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||||
|
val selectedIds = controller.peekCheckedIds()
|
||||||
|
val allItems = viewModel.chapters.value
|
||||||
|
val items = allItems.withIndex().filter { it.value.chapter.id in selectedIds }
|
||||||
|
var canSave = true
|
||||||
|
var canDelete = true
|
||||||
|
items.forEach { (_, x) ->
|
||||||
|
val isLocal = x.isDownloaded || x.chapter.source == LocalMangaSource
|
||||||
|
if (isLocal) canSave = false else canDelete = false
|
||||||
|
}
|
||||||
|
menu.findItem(R.id.action_save).isVisible = canSave
|
||||||
|
menu.findItem(R.id.action_delete).isVisible = canDelete
|
||||||
|
menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size
|
||||||
|
menu.findItem(R.id.action_mark_current).isVisible = items.size == 1
|
||||||
|
mode.title = items.size.toString()
|
||||||
|
var hasGap = false
|
||||||
|
for (i in 0 until items.size - 1) {
|
||||||
|
if (items[i].index + 1 != items[i + 1].index) {
|
||||||
|
hasGap = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
menu.findItem(R.id.action_select_range).isVisible = hasGap
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
|
||||||
|
return when (item.itemId) {
|
||||||
|
R.id.action_save -> {
|
||||||
|
viewModel.download(controller.snapshot())
|
||||||
|
mode.finish()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.action_delete -> {
|
||||||
|
val ids = controller.peekCheckedIds()
|
||||||
|
val manga = viewModel.getMangaOrNull()
|
||||||
|
when {
|
||||||
|
ids.isEmpty() || manga == null -> Unit
|
||||||
|
ids.size == manga.chapters?.size -> viewModel.deleteLocal()
|
||||||
|
else -> {
|
||||||
|
LocalChaptersRemoveService.start(recyclerView.context, manga, ids.toSet())
|
||||||
|
Snackbar.make(
|
||||||
|
recyclerView,
|
||||||
|
R.string.chapters_will_removed_background,
|
||||||
|
Snackbar.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mode.finish()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.action_select_range -> {
|
||||||
|
val items = viewModel.chapters.value
|
||||||
|
val ids = controller.peekCheckedIds().toCollection(HashSet())
|
||||||
|
val buffer = HashSet<Long>()
|
||||||
|
var isAdding = false
|
||||||
|
for (x in items) {
|
||||||
|
if (x.chapter.id in ids) {
|
||||||
|
isAdding = true
|
||||||
|
if (buffer.isNotEmpty()) {
|
||||||
|
ids.addAll(buffer)
|
||||||
|
buffer.clear()
|
||||||
|
}
|
||||||
|
} else if (isAdding) {
|
||||||
|
buffer.add(x.chapter.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controller.addAll(ids)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.action_select_all -> {
|
||||||
|
val ids = viewModel.chapters.value.map {
|
||||||
|
it.chapter.id
|
||||||
|
}
|
||||||
|
controller.addAll(ids)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.action_mark_current -> {
|
||||||
|
val ids = controller.peekCheckedIds()
|
||||||
|
if (ids.size == 1) {
|
||||||
|
viewModel.markChapterAsCurrent(ids.first())
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mode.finish()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package org.koitharu.kotatsu.download.domain
|
||||||
|
|
||||||
|
data class DownloadProgress(
|
||||||
|
val totalChapters: Int,
|
||||||
|
val currentChapter: Int,
|
||||||
|
val totalPages: Int,
|
||||||
|
val currentPage: Int,
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue