Compare commits
94 Commits
feature/st
...
v6.8.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ffefddb86 | ||
|
|
09b154c997 | ||
|
|
d9f3b4f76e | ||
|
|
8ebb3ef804 | ||
|
|
b03682a81f | ||
|
|
5dd54be06c | ||
|
|
98c0b60207 | ||
|
|
10a0009532 | ||
|
|
5e203f0b27 | ||
|
|
46fc48cfd7 | ||
|
|
e8a17708d2 | ||
|
|
bc42fda786 | ||
|
|
d3590372f3 | ||
|
|
88f55997fa | ||
|
|
0a1bc6716b | ||
|
|
559e546462 | ||
|
|
6c5775a2ed | ||
|
|
4858adbbe7 | ||
|
|
cae07b2798 | ||
|
|
b14603c384 | ||
|
|
2f21d0f0f8 | ||
|
|
7e182cb0ad | ||
|
|
f79d2cb733 | ||
|
|
ce296900c5 | ||
|
|
0156ae86eb | ||
|
|
efd82b6d96 | ||
|
|
b4371d2cd2 | ||
|
|
676c94d759 | ||
|
|
b4c8fb7f9b | ||
|
|
5f79d37506 | ||
|
|
2e074573c0 | ||
|
|
82281312fb | ||
|
|
ed6a906459 | ||
|
|
00b01f298d | ||
|
|
aa99ea1245 | ||
|
|
732c614aad | ||
|
|
afe16859d4 | ||
|
|
c95f2aa9a1 | ||
|
|
630cece4f5 | ||
|
|
f0101bc183 | ||
|
|
7c2829226d | ||
|
|
83bd390c2a | ||
|
|
b090652007 | ||
|
|
569edd91c9 | ||
|
|
2e81684652 | ||
|
|
2573d150f9 | ||
|
|
24fe83aa5c | ||
|
|
bbc39becc3 | ||
|
|
65077c1fba | ||
|
|
bec0ce2c96 | ||
|
|
256f0a31bc | ||
|
|
b8e48d8b8a | ||
|
|
8313d6966f | ||
|
|
7e581a5ed7 | ||
|
|
16027e3295 | ||
|
|
e4e14214d9 | ||
|
|
e40a39ca28 | ||
|
|
82e711d619 | ||
|
|
8c2bff78f7 | ||
|
|
4f2c38d4ee | ||
|
|
3c54fe4217 | ||
|
|
750bf11fdc | ||
|
|
b2c5ec5082 | ||
|
|
f97d4d452f | ||
|
|
640fe272c8 | ||
|
|
f730e80bb7 | ||
|
|
d975e92991 | ||
|
|
0d29190bd1 | ||
|
|
76c07b1567 | ||
|
|
55c82a6f5c | ||
|
|
f81d298315 | ||
|
|
fd17e1ea20 | ||
|
|
a1dc401eee | ||
|
|
b5ee465cde | ||
|
|
956b04e974 | ||
|
|
f65c213e2d | ||
|
|
813ce2e195 | ||
|
|
0eb320ec76 | ||
|
|
b17aa6c031 | ||
|
|
97b5102e6c | ||
|
|
fe408c0832 | ||
|
|
4f3d1a9814 | ||
|
|
65abfc3a49 | ||
|
|
ba88ca8234 | ||
|
|
63470db6f5 | ||
|
|
1e39ae48ec | ||
|
|
6fcc45d554 | ||
|
|
094b0f694c | ||
|
|
51362e6cce | ||
|
|
81d4a3cf68 | ||
|
|
c2e30b3009 | ||
|
|
0c823f1056 | ||
|
|
44adbde536 | ||
|
|
ae0b405ba5 |
3
.idea/gradle.xml
generated
3
.idea/gradle.xml
generated
@@ -4,9 +4,8 @@
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="GRADLE" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="jbr-17" />
|
||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
|
||||
53
LICENSE
53
LICENSE
@@ -619,56 +619,3 @@ Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
@@ -16,8 +16,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
versionCode = 626
|
||||
versionName = '6.7.4'
|
||||
versionCode = 631
|
||||
versionName = '6.8.1'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
@@ -82,19 +82,19 @@ afterEvaluate {
|
||||
}
|
||||
dependencies {
|
||||
//noinspection GradleDependency
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:103f578c61') {
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:9821e93d25') {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.23'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0'
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.activity:activity-ktx:1.8.2'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.6.2'
|
||||
implementation 'androidx.collection:collection:1.4.0'
|
||||
implementation 'androidx.collection:collection-ktx:1.4.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.7.0'
|
||||
@@ -104,8 +104,9 @@ dependencies {
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
||||
implementation 'com.google.android.material:material:1.12.0-alpha03'
|
||||
implementation 'com.google.android.material:material:1.12.0-beta01'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.7.0'
|
||||
implementation 'androidx.webkit:webkit:1.10.0'
|
||||
|
||||
implementation 'androidx.work:work-runtime:2.9.0'
|
||||
//noinspection GradleDependency
|
||||
@@ -121,7 +122,7 @@ dependencies {
|
||||
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
|
||||
implementation 'com.squareup.okio:okio:3.8.0'
|
||||
implementation 'com.squareup.okio:okio:3.9.0'
|
||||
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||
@@ -147,7 +148,7 @@ dependencies {
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.13'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.json:json:20240205'
|
||||
testImplementation 'org.json:json:20240303'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0'
|
||||
|
||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||
|
||||
@@ -242,6 +242,9 @@
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.stats.ui.StatsActivity"
|
||||
android:label="@string/reading_stats" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.alternatives.ui.AlternativesActivity"
|
||||
android:label="@string/alternatives" />
|
||||
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package org.koitharu.kotatsu.alternatives.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.almostEquals
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val MAX_PARALLELISM = 4
|
||||
private const val MATCH_THRESHOLD = 0.2f
|
||||
|
||||
class AlternativesUseCase @Inject constructor(
|
||||
private val sourcesRepository: MangaSourcesRepository,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(manga: Manga): Flow<Manga> {
|
||||
val sources = getSources(manga.source)
|
||||
if (sources.isEmpty()) {
|
||||
return emptyFlow()
|
||||
}
|
||||
val semaphore = Semaphore(MAX_PARALLELISM)
|
||||
return channelFlow {
|
||||
for (source in sources) {
|
||||
val repository = mangaRepositoryFactory.create(source)
|
||||
if (!repository.isSearchSupported) {
|
||||
continue
|
||||
}
|
||||
launch {
|
||||
val list = runCatchingCancellable {
|
||||
semaphore.withPermit {
|
||||
repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
for (item in list) {
|
||||
if (item.matches(manga)) {
|
||||
send(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.map {
|
||||
runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(it.source).getDetails(it)
|
||||
}.getOrDefault(it)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getSources(ref: MangaSource): List<MangaSource> {
|
||||
val result = ArrayList<MangaSource>(MangaSource.entries.size - 2)
|
||||
result.addAll(sourcesRepository.getEnabledSources())
|
||||
result.sortByDescending { it.priority(ref) }
|
||||
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
|
||||
return result
|
||||
}
|
||||
|
||||
private fun Manga.matches(ref: Manga): Boolean {
|
||||
return matchesTitles(title, ref.title) ||
|
||||
matchesTitles(title, ref.altTitle) ||
|
||||
matchesTitles(altTitle, ref.title) ||
|
||||
matchesTitles(altTitle, ref.altTitle)
|
||||
|
||||
}
|
||||
|
||||
private fun matchesTitles(a: String?, b: String?): Boolean {
|
||||
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, MATCH_THRESHOLD)
|
||||
}
|
||||
|
||||
private fun MangaSource.priority(ref: MangaSource): Int {
|
||||
var res = 0
|
||||
if (locale == ref.locale) res += 2
|
||||
if (contentType == ref.contentType) res++
|
||||
return res
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package org.koitharu.kotatsu.alternatives.domain
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
|
||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
|
||||
import org.koitharu.kotatsu.history.data.toMangaHistory
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
|
||||
class MigrateUseCase @Inject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
private val database: MangaDatabase,
|
||||
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(oldManga: Manga, newManga: Manga) {
|
||||
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
|
||||
runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||
}.getOrDefault(oldManga)
|
||||
} else {
|
||||
oldManga
|
||||
}
|
||||
val newDetails = if (newManga.chapters.isNullOrEmpty()) {
|
||||
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
||||
} else {
|
||||
newManga
|
||||
}
|
||||
mangaDataRepository.storeManga(newDetails)
|
||||
database.withTransaction {
|
||||
// replace favorites
|
||||
val favoritesDao = database.getFavouritesDao()
|
||||
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
|
||||
if (oldFavourites.isNotEmpty()) {
|
||||
favoritesDao.delete(oldManga.id)
|
||||
for (f in oldFavourites) {
|
||||
val e = f.copy(
|
||||
mangaId = newManga.id,
|
||||
)
|
||||
favoritesDao.upsert(e)
|
||||
}
|
||||
}
|
||||
// replace history
|
||||
val historyDao = database.getHistoryDao()
|
||||
val oldHistory = historyDao.find(oldDetails.id)
|
||||
if (oldHistory != null) {
|
||||
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
|
||||
historyDao.delete(oldDetails.id)
|
||||
historyDao.upsert(newHistory)
|
||||
}
|
||||
}
|
||||
progressUpdateUseCase(newManga)
|
||||
}
|
||||
|
||||
private fun makeNewHistory(
|
||||
oldManga: Manga,
|
||||
newManga: Manga,
|
||||
history: HistoryEntity,
|
||||
): HistoryEntity {
|
||||
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
||||
val branch = newManga.getPreferredBranch(null)
|
||||
val chapters = checkNotNull(newManga.getChapters(branch))
|
||||
val currentChapter = if (history.percent in 0f..1f) {
|
||||
chapters[(chapters.lastIndex * history.percent).toInt()]
|
||||
} else {
|
||||
chapters.first()
|
||||
}
|
||||
return HistoryEntity(
|
||||
mangaId = newManga.id,
|
||||
createdAt = history.createdAt,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
chapterId = currentChapter.id,
|
||||
page = history.page,
|
||||
scroll = history.scroll,
|
||||
percent = history.percent,
|
||||
deletedAt = 0,
|
||||
chaptersCount = chapters.size,
|
||||
)
|
||||
}
|
||||
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
|
||||
val oldChapters = checkNotNull(oldManga.getChapters(branch))
|
||||
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
|
||||
if (index < 0) {
|
||||
index = if (history.percent in 0f..1f) {
|
||||
(oldChapters.lastIndex * history.percent).toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
|
||||
val newBranch = if (newChapters.containsKey(branch)) {
|
||||
branch
|
||||
} else {
|
||||
newManga.getPreferredBranch(null)
|
||||
}
|
||||
val newChapterId = checkNotNull(newChapters[newBranch]).let {
|
||||
val oldChapter = oldChapters[index]
|
||||
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
|
||||
}.id
|
||||
|
||||
return HistoryEntity(
|
||||
mangaId = newManga.id,
|
||||
createdAt = history.createdAt,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
chapterId = newChapterId,
|
||||
page = history.page,
|
||||
scroll = history.scroll,
|
||||
percent = PROGRESS_NONE,
|
||||
deletedAt = 0,
|
||||
chaptersCount = checkNotNull(newChapters[newBranch]).size,
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<MangaChapter>.findByNumber(volume: Int, number: Float): MangaChapter? {
|
||||
return if (number <= 0f) {
|
||||
null
|
||||
} else {
|
||||
firstOrNull { it.volume == volume && it.number == number }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.transform.CircleCropTransformation
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
|
||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import kotlin.math.sign
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
fun alternativeAD(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
listener: OnListItemClickListener<MangaAlternativeModel>,
|
||||
) = adapterDelegateViewBinding<MangaAlternativeModel, ListModel, ItemMangaAlternativeBinding>(
|
||||
{ inflater, parent -> ItemMangaAlternativeBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
|
||||
val colorGreen = ContextCompat.getColor(context, R.color.common_green)
|
||||
val colorRed = ContextCompat.getColor(context, R.color.common_red)
|
||||
val clickListener = AdapterDelegateClickListenerAdapter(this, listener)
|
||||
itemView.setOnClickListener(clickListener)
|
||||
binding.buttonMigrate.setOnClickListener(clickListener)
|
||||
binding.chipSource.setOnClickListener(clickListener)
|
||||
|
||||
bind { payloads ->
|
||||
binding.textViewTitle.text = item.manga.title
|
||||
binding.textViewSubtitle.text = buildSpannedString {
|
||||
if (item.chaptersCount > 0) {
|
||||
append(context.resources.getQuantityString(R.plurals.chapters, item.chaptersCount, item.chaptersCount))
|
||||
} else {
|
||||
append(context.getString(R.string.no_chapters))
|
||||
}
|
||||
when (item.chaptersDiff.sign) {
|
||||
-1 -> inSpans(ForegroundColorSpan(colorRed)) {
|
||||
append(" ▼ ")
|
||||
append(item.chaptersDiff.toString())
|
||||
}
|
||||
|
||||
1 -> inSpans(ForegroundColorSpan(colorGreen)) {
|
||||
append(" ▲ +")
|
||||
append(item.chaptersDiff.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
|
||||
binding.chipSource.also { chip ->
|
||||
chip.text = item.manga.source.title
|
||||
ImageRequest.Builder(context)
|
||||
.data(item.manga.source.faviconUri())
|
||||
.lifecycle(lifecycleOwner)
|
||||
.crossfade(false)
|
||||
.size(context.resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
|
||||
.target(ChipIconTarget(chip))
|
||||
.placeholder(R.drawable.ic_web)
|
||||
.fallback(R.drawable.ic_web)
|
||||
.error(R.drawable.ic_web)
|
||||
.source(item.manga.source)
|
||||
.transformations(CircleCropTransformation())
|
||||
.allowRgb565(true)
|
||||
.enqueueWith(coil)
|
||||
}
|
||||
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
|
||||
size(CoverSizeResolver(binding.imageViewCover))
|
||||
placeholder(R.drawable.ic_placeholder)
|
||||
fallback(R.drawable.ic_placeholder)
|
||||
error(R.drawable.ic_error_placeholder)
|
||||
transformations(TrimTransformation())
|
||||
allowRgb565(true)
|
||||
tag(item.manga)
|
||||
source(item.manga.source)
|
||||
enqueueWith(coil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import coil.ImageLoader
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||
OnListItemClickListener<MangaAlternativeModel> {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private val viewModel by viewModels<AlternativesViewModel>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityAlternativesBinding.inflate(layoutInflater))
|
||||
supportActionBar?.run {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
subtitle = viewModel.manga.title
|
||||
}
|
||||
val listAdapter = BaseListAdapter<ListModel>()
|
||||
.addDelegate(ListItemType.MANGA_LIST_DETAILED, alternativeAD(coil, this, this))
|
||||
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, null))
|
||||
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||
with(viewBinding.recyclerView) {
|
||||
setHasFixedSize(true)
|
||||
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
|
||||
adapter = listAdapter
|
||||
}
|
||||
|
||||
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
|
||||
viewModel.content.observe(this, listAdapter)
|
||||
viewModel.onMigrated.observeEvent(this) {
|
||||
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
|
||||
startActivity(DetailsActivity.newIntent(this, it))
|
||||
finishAfterTransition()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.root.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
)
|
||||
viewBinding.recyclerView.updatePadding(
|
||||
bottom = insets.bottom + viewBinding.recyclerView.paddingTop,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
||||
when (view.id) {
|
||||
R.id.chip_source -> startActivity(SearchActivity.newIntent(this, item.manga.source, viewModel.manga.title))
|
||||
R.id.button_migrate -> confirmMigration(item.manga)
|
||||
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmMigration(target: Manga) {
|
||||
MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED)
|
||||
.setIcon(R.drawable.ic_replace)
|
||||
.setTitle(R.string.manga_migration)
|
||||
.setMessage(
|
||||
getString(
|
||||
R.string.migrate_confirmation,
|
||||
viewModel.manga.title,
|
||||
viewModel.manga.source.title,
|
||||
target.title,
|
||||
target.source.title,
|
||||
),
|
||||
)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.migrate) { _, _ ->
|
||||
viewModel.migrate(target)
|
||||
}.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newIntent(context: Context, manga: Manga) = Intent(context, AlternativesActivity::class.java)
|
||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEmpty
|
||||
import kotlinx.coroutines.flow.runningFold
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
|
||||
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
|
||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AlternativesViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val alternativesUseCase: AlternativesUseCase,
|
||||
private val migrateUseCase: MigrateUseCase,
|
||||
private val extraProvider: ListExtraProvider,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
|
||||
|
||||
val onMigrated = MutableEventFlow<Manga>()
|
||||
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
|
||||
private var migrationJob: Job? = null
|
||||
|
||||
init {
|
||||
launchJob(Dispatchers.Default) {
|
||||
val ref = runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(manga.source).getDetails(manga)
|
||||
}.getOrDefault(manga)
|
||||
val refCount = ref.chaptersCount()
|
||||
alternativesUseCase(ref)
|
||||
.map {
|
||||
MangaAlternativeModel(
|
||||
manga = it,
|
||||
progress = extraProvider.getProgress(it.id),
|
||||
referenceChapters = refCount,
|
||||
)
|
||||
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
|
||||
acc.filterIsInstance<MangaAlternativeModel>() + item + LoadingFooter()
|
||||
}.onEmpty {
|
||||
emit(
|
||||
listOf(
|
||||
EmptyState(
|
||||
icon = R.drawable.ic_empty_common,
|
||||
textPrimary = R.string.nothing_found,
|
||||
textSecondary = R.string.text_search_holder_secondary,
|
||||
actionStringRes = 0,
|
||||
),
|
||||
),
|
||||
)
|
||||
}.collect {
|
||||
content.value = it
|
||||
}
|
||||
content.value = content.value.filterNot { it is LoadingFooter }
|
||||
}
|
||||
}
|
||||
|
||||
fun migrate(target: Manga) {
|
||||
if (migrationJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
migrationJob = launchLoadingJob(Dispatchers.Default) {
|
||||
migrateUseCase(manga, target)
|
||||
onMigrated.call(target)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun mapList(list: List<Manga>, refCount: Int): List<MangaAlternativeModel> {
|
||||
return list.map {
|
||||
MangaAlternativeModel(
|
||||
manga = it,
|
||||
progress = extraProvider.getProgress(it.id),
|
||||
referenceChapters = refCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
data class MangaAlternativeModel(
|
||||
val manga: Manga,
|
||||
val progress: Float,
|
||||
private val referenceChapters: Int,
|
||||
) : ListModel {
|
||||
|
||||
val chaptersCount = manga.chaptersCount()
|
||||
|
||||
val chaptersDiff: Int
|
||||
get() = if (referenceChapters == 0 || chaptersCount == 0) 0 else chaptersCount - referenceChapters
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
return other is MangaAlternativeModel && other.manga.id == manga.id
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,9 @@ import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@@ -32,10 +33,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||
}
|
||||
with(viewBinding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
userAgentString = UserAgents.CHROME_MOBILE
|
||||
}
|
||||
viewBinding.webView.configureForParser(null)
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||
@@ -80,11 +78,14 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
}
|
||||
|
||||
R.id.action_browser -> {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = Uri.parse(viewBinding.webView.url)
|
||||
try {
|
||||
startActivity(Intent.createChooser(intent, item.title))
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
val url = viewBinding.webView.url?.toUriOrNull()
|
||||
if (url != null) {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = url
|
||||
try {
|
||||
startActivity(Intent.createChooser(intent, item.title))
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -40,6 +40,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
@Inject
|
||||
lateinit var cookieJar: MutableCookieJar
|
||||
|
||||
private lateinit var cfClient: CloudFlareClient
|
||||
private var onBackPressedCallback: WebViewBackPressedCallback? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -52,13 +53,9 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||
}
|
||||
val url = intent?.dataString.orEmpty()
|
||||
with(viewBinding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
userAgentString = intent?.getStringExtra(ARG_UA) ?: UserAgents.CHROME_MOBILE
|
||||
}
|
||||
viewBinding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
|
||||
cfClient = CloudFlareClient(cookieJar, this, url)
|
||||
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
|
||||
viewBinding.webView.webViewClient = cfClient
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
|
||||
onBackPressedDispatcher.addCallback(it)
|
||||
}
|
||||
@@ -118,15 +115,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
restartCheck()
|
||||
true
|
||||
}
|
||||
|
||||
@@ -152,6 +141,10 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
viewBinding.progressBar.isInvisible = true
|
||||
}
|
||||
|
||||
override fun onLoopDetected() {
|
||||
restartCheck()
|
||||
}
|
||||
|
||||
override fun onCheckPassed() {
|
||||
pendingResult = RESULT_OK
|
||||
finishAfterTransition()
|
||||
@@ -171,10 +164,23 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
|
||||
}
|
||||
|
||||
private fun restartCheck() {
|
||||
lifecycleScope.launch {
|
||||
viewBinding.webView.stopLoading()
|
||||
yield()
|
||||
cfClient.reset()
|
||||
val targetUrl = intent?.dataString?.toHttpUrlOrNull()
|
||||
if (targetUrl != null) {
|
||||
clearCfCookies(targetUrl)
|
||||
viewBinding.webView.loadUrl(targetUrl.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf") || name == "csrftoken"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,4 +11,6 @@ interface CloudFlareCallback : BrowserCallback {
|
||||
fun onPageLoaded()
|
||||
|
||||
fun onCheckPassed()
|
||||
|
||||
fun onLoopDetected()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.koitharu.kotatsu.browser.BrowserClient
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
|
||||
private const val CF_CLEARANCE = "cf_clearance"
|
||||
private const val LOOP_COUNTER = 3
|
||||
|
||||
class CloudFlareClient(
|
||||
private val cookieJar: MutableCookieJar,
|
||||
@@ -15,6 +16,7 @@ class CloudFlareClient(
|
||||
) : BrowserClient(callback) {
|
||||
|
||||
private val oldClearance = getClearance()
|
||||
private var counter = 0
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
@@ -31,10 +33,20 @@ class CloudFlareClient(
|
||||
callback.onPageLoaded()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
counter = 0
|
||||
}
|
||||
|
||||
private fun checkClearance() {
|
||||
val clearance = getClearance()
|
||||
if (clearance != null && clearance != oldClearance) {
|
||||
callback.onCheckPassed()
|
||||
} else {
|
||||
counter++
|
||||
if (counter >= LOOP_COUNTER) {
|
||||
reset()
|
||||
callback.onLoopDetected()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ interface ContentCache {
|
||||
|
||||
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>)
|
||||
|
||||
fun clear(source: MangaSource)
|
||||
|
||||
data class Key(
|
||||
val source: MangaSource,
|
||||
val url: String,
|
||||
|
||||
@@ -7,10 +7,12 @@ class ExpiringLruCache<T>(
|
||||
val maxSize: Int,
|
||||
private val lifetime: Long,
|
||||
private val timeUnit: TimeUnit,
|
||||
) {
|
||||
) : Iterable<ContentCache.Key> {
|
||||
|
||||
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
|
||||
|
||||
override fun iterator(): Iterator<ContentCache.Key> = cache.snapshot().keys.iterator()
|
||||
|
||||
operator fun get(key: ContentCache.Key): T? {
|
||||
val value = cache[key] ?: return null
|
||||
if (value.isExpired) {
|
||||
@@ -30,4 +32,8 @@ class ExpiringLruCache<T>(
|
||||
fun trimToSize(size: Int) {
|
||||
cache.trimToSize(size)
|
||||
}
|
||||
|
||||
fun remove(key: ContentCache.Key) {
|
||||
cache.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,12 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
|
||||
relatedMangaCache[ContentCache.Key(source, url)] = related
|
||||
}
|
||||
|
||||
override fun clear(source: MangaSource) {
|
||||
clearCache(detailsCache, source)
|
||||
clearCache(pagesCache, source)
|
||||
clearCache(relatedMangaCache, source)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) = Unit
|
||||
|
||||
override fun onLowMemory() = Unit
|
||||
@@ -67,4 +73,12 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
|
||||
else -> cache.trimToSize(cache.maxSize / 2)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) {
|
||||
cache.forEach { key ->
|
||||
if (key.source == source) {
|
||||
cache.remove(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,4 +19,6 @@ class StubContentCache : ContentCache {
|
||||
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? = null
|
||||
|
||||
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) = Unit
|
||||
|
||||
override fun clear(source: MangaSource) = Unit
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ abstract class MangaSourcesDao {
|
||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
||||
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
||||
|
||||
@Query("SELECT enabled FROM sources WHERE source = :source")
|
||||
abstract fun observeIsEnabled(source: String): Flow<Boolean>
|
||||
|
||||
@Query("SELECT IFNULL(MAX(sort_key),0) FROM sources")
|
||||
abstract suspend fun getMaxSortKey(): Int
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
class UnsupportedSourceException(
|
||||
message: String?,
|
||||
val manga: Manga?,
|
||||
) : IllegalArgumentException(message)
|
||||
@@ -21,7 +21,7 @@ abstract class ErrorObserver(
|
||||
private val onResolved: Consumer<Boolean>?,
|
||||
) : FlowCollector<Throwable> {
|
||||
|
||||
protected val activity = host.context.findActivity()
|
||||
protected open val activity = host.context.findActivity()
|
||||
|
||||
private val lifecycleScope: LifecycleCoroutineScope
|
||||
get() = checkNotNull(fragment?.viewLifecycleScope ?: (activity as? LifecycleOwner)?.lifecycle?.coroutineScope)
|
||||
@@ -36,7 +36,7 @@ abstract class ErrorObserver(
|
||||
private fun isAlive(): Boolean {
|
||||
return when {
|
||||
fragment != null -> fragment.view != null
|
||||
activity != null -> !activity.isDestroyed
|
||||
activity != null -> activity?.isDestroyed == false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,16 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import okhttp3.Headers
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||
import kotlin.coroutines.Continuation
|
||||
@@ -59,6 +62,11 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
false
|
||||
}
|
||||
|
||||
is UnsupportedSourceException -> {
|
||||
e.manga?.let { openAlternatives(it) }
|
||||
false
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
@@ -77,6 +85,11 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
context.startActivity(BrowserActivity.newIntent(context, url, null))
|
||||
}
|
||||
|
||||
private fun openAlternatives(manga: Manga) {
|
||||
val context = activity ?: fragment?.activity ?: return
|
||||
context.startActivity(AlternativesActivity.newIntent(context, manga))
|
||||
}
|
||||
|
||||
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
|
||||
|
||||
companion object {
|
||||
@@ -86,6 +99,7 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
is CloudFlareProtectedException -> R.string.captcha_solve
|
||||
is AuthRequiredException -> R.string.sign_in
|
||||
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
|
||||
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
||||
else -> 0
|
||||
}
|
||||
|
||||
|
||||
@@ -131,3 +131,19 @@ fun MangaChapter.formatNumber(): String? {
|
||||
}
|
||||
return chaptersNumberFormat.format(number.toDouble())
|
||||
}
|
||||
|
||||
fun Manga.chaptersCount(): Int {
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
return 0
|
||||
}
|
||||
val counters = MutableObjectIntMap<String?>()
|
||||
var max = 0
|
||||
chapters?.forEach { x ->
|
||||
val c = counters.getOrDefault(x.branch, 0) + 1
|
||||
counters[x.branch] = c
|
||||
if (max < c) {
|
||||
max = c
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import org.koitharu.kotatsu.parsers.util.mergeWith
|
||||
import java.net.IDN
|
||||
import javax.inject.Inject
|
||||
@@ -20,6 +20,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class CommonHeadersInterceptor @Inject constructor(
|
||||
private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>,
|
||||
private val mangaLoaderContextLazy: Lazy<MangaLoaderContextImpl>,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
@@ -38,7 +39,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
||||
headersBuilder.mergeWith(it, replaceExisting = false)
|
||||
}
|
||||
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
|
||||
headersBuilder[CommonHeaders.USER_AGENT] = UserAgents.CHROME_MOBILE
|
||||
headersBuilder[CommonHeaders.USER_AGENT] = mangaLoaderContextLazy.get().getDefaultUserAgent()
|
||||
}
|
||||
if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) {
|
||||
val idn = IDN.toASCII(repository.domain)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
@@ -18,24 +19,20 @@ import java.util.EnumSet
|
||||
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
|
||||
|
||||
override val configKeyDomain: ConfigKey.Domain
|
||||
get() = ConfigKey.Domain("")
|
||||
get() = ConfigKey.Domain("localhost")
|
||||
|
||||
override val availableSortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
|
||||
|
||||
override suspend fun getAvailableTags(): Set<MangaTag> {
|
||||
TODO("Not yet implemented")
|
||||
override suspend fun getAvailableTags(): Set<MangaTag> = stub(null)
|
||||
|
||||
private fun stub(manga: Manga?): Nothing {
|
||||
throw UnsupportedSourceException("Usage of Dummy parser", manga)
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,25 @@ import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import android.webkit.WebView
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
|
||||
import org.koitharu.kotatsu.core.util.ext.toList
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
@@ -32,12 +39,15 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
|
||||
private var webViewCached: WeakReference<WebView>? = null
|
||||
|
||||
private val userAgentLazy = SuspendLazy {
|
||||
withContext(Dispatchers.Main) {
|
||||
obtainWebView().settings.userAgentString
|
||||
}.sanitizeHeaderValue()
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main) {
|
||||
val webView = webViewCached?.get() ?: WebView(androidContext).also {
|
||||
it.settings.javaScriptEnabled = true
|
||||
webViewCached = WeakReference(it)
|
||||
}
|
||||
val webView = obtainWebView()
|
||||
suspendCoroutine { cont ->
|
||||
webView.evaluateJavascript(script) { result ->
|
||||
cont.resume(result?.takeUnless { it == "null" })
|
||||
@@ -45,6 +55,14 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDefaultUserAgent(): String = runCatching {
|
||||
runBlocking {
|
||||
userAgentLazy.get()
|
||||
}
|
||||
}.onFailure { e ->
|
||||
e.printStackTraceDebug()
|
||||
}.getOrDefault(UserAgents.FIREFOX_MOBILE)
|
||||
|
||||
override fun getConfig(source: MangaSource): MangaSourceConfig {
|
||||
return SourceSettings(androidContext, source)
|
||||
}
|
||||
@@ -60,4 +78,12 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
override fun getPreferredLocales(): List<Locale> {
|
||||
return LocaleListCompat.getAdjustedDefault().toList()
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun obtainWebView(): WebView {
|
||||
return webViewCached?.get() ?: WebView(androidContext).also {
|
||||
it.configureForParser(null)
|
||||
webViewCached = WeakReference(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,11 @@ class RemoteMangaRepository(
|
||||
return getConfig().isSlowdownEnabled
|
||||
}
|
||||
|
||||
private fun getConfig() = parser.config as SourceSettings
|
||||
fun invalidateCache() {
|
||||
cache.clear(source)
|
||||
}
|
||||
|
||||
fun getConfig() = parser.config as SourceSettings
|
||||
|
||||
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
|
||||
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
|
||||
|
||||
@@ -27,7 +27,6 @@ import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.find
|
||||
import org.koitharu.kotatsu.parsers.util.isNumeric
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
|
||||
@@ -179,10 +178,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getBoolean(KEY_INCOGNITO_MODE, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_INCOGNITO_MODE, value) }
|
||||
|
||||
var chaptersReverse: Boolean
|
||||
var isChaptersReverse: Boolean
|
||||
get() = prefs.getBoolean(KEY_REVERSE_CHAPTERS, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_REVERSE_CHAPTERS, value) }
|
||||
|
||||
var isChaptersGridView: Boolean
|
||||
get() = prefs.getBoolean(KEY_GRID_VIEW_CHAPTERS, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_GRID_VIEW_CHAPTERS, value) }
|
||||
|
||||
val zoomMode: ZoomMode
|
||||
get() = prefs.getEnumValue(KEY_ZOOM_MODE, ZoomMode.FIT_CENTER)
|
||||
|
||||
@@ -218,8 +221,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isUnstableUpdatesAllowed: Boolean
|
||||
get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false)
|
||||
|
||||
val isPagesTabEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_PAGES_TAB, true)
|
||||
|
||||
val defaultDetailsTab: Int
|
||||
get() = prefs.getString(KEY_DETAILS_TAB, null)?.toIntOrNull()?.coerceIn(0, 1) ?: 0
|
||||
get() = if (isPagesTabEnabled) {
|
||||
prefs.getString(KEY_DETAILS_TAB, null)?.toIntOrNull()?.coerceIn(0, 1) ?: 0
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
val isContentPrefetchEnabled: Boolean
|
||||
get() {
|
||||
@@ -425,6 +435,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isStatsEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_STATS_ENABLED, false)
|
||||
|
||||
val isAutoLocalChaptersCleanupEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_CHAPTERS_CLEAR_AUTO, false)
|
||||
|
||||
fun isTipEnabled(tip: String): Boolean {
|
||||
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
|
||||
}
|
||||
@@ -508,6 +521,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
|
||||
const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear"
|
||||
const val KEY_COOKIES_CLEAR = "cookies_clear"
|
||||
const val KEY_CHAPTERS_CLEAR = "chapters_clear"
|
||||
const val KEY_CHAPTERS_CLEAR_AUTO = "chapters_clear_auto"
|
||||
const val KEY_THUMBS_CACHE_CLEAR = "thumbs_cache_clear"
|
||||
const val KEY_SEARCH_HISTORY_CLEAR = "search_history_clear"
|
||||
const val KEY_UPDATES_FEED_CLEAR = "updates_feed_clear"
|
||||
@@ -548,6 +563,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_HISTORY_GROUPING = "history_grouping"
|
||||
const val KEY_READING_INDICATORS = "reading_indicators"
|
||||
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
|
||||
const val KEY_GRID_VIEW_CHAPTERS = "grid_view_chapters"
|
||||
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
|
||||
const val KEY_PAGES_NUMBERS = "pages_numbers"
|
||||
const val KEY_SCREENSHOTS_POLICY = "screenshots_policy"
|
||||
@@ -613,6 +629,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_CF_INVERTED = "cf_inverted"
|
||||
const val KEY_CF_GRAYSCALE = "cf_grayscale"
|
||||
const val KEY_IGNORE_DOZE = "ignore_dose"
|
||||
const val KEY_PAGES_TAB = "pages_tab"
|
||||
const val KEY_DETAILS_TAB = "details_tab"
|
||||
const val KEY_READING_TIME = "reading_time"
|
||||
const val KEY_PAGES_SAVE_DIR = "pages_dir"
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||
import androidx.core.content.edit
|
||||
import okhttp3.internal.isSensitiveHeader
|
||||
import org.koitharu.kotatsu.core.util.ext.getEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
|
||||
private const val KEY_SORT_ORDER = "sort_order"
|
||||
private const val KEY_SLOWDOWN = "slowdown"
|
||||
|
||||
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
|
||||
|
||||
private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE)
|
||||
@@ -27,9 +27,13 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T> get(key: ConfigKey<T>): T {
|
||||
return when (key) {
|
||||
is ConfigKey.UserAgent -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
|
||||
is ConfigKey.UserAgent -> prefs.getString(key.key, key.defaultValue)
|
||||
.ifNullOrEmpty { key.defaultValue }
|
||||
.sanitizeHeaderValue()
|
||||
|
||||
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
|
||||
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
|
||||
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
|
||||
} as T
|
||||
}
|
||||
|
||||
@@ -37,7 +41,22 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
||||
when (key) {
|
||||
is ConfigKey.Domain -> putString(key.key, value as String?)
|
||||
is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
|
||||
is ConfigKey.UserAgent -> putString(key.key, value as String?)
|
||||
is ConfigKey.UserAgent -> putString(key.key, (value as String?)?.sanitizeHeaderValue())
|
||||
is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
fun subscribe(listener: OnSharedPreferenceChangeListener) {
|
||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
fun unsubscribe(listener: OnSharedPreferenceChangeListener) {
|
||||
prefs.unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val KEY_SORT_ORDER = "sort_order"
|
||||
const val KEY_SLOWDOWN = "slowdown"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,11 +60,11 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
if (isAmoledTheme) {
|
||||
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
|
||||
}
|
||||
putDataToExtras(intent)
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
insetsDelegate.handleImeInsets = true
|
||||
insetsDelegate.addInsetsListener(this)
|
||||
putDataToExtras(intent)
|
||||
}
|
||||
|
||||
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.koitharu.kotatsu.core.ui.image
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import coil.target.GenericViewTarget
|
||||
import com.google.android.material.chip.Chip
|
||||
|
||||
class ChipIconTarget(override val view: Chip) : GenericViewTarget<Chip>() {
|
||||
|
||||
override var drawable: Drawable?
|
||||
get() = view.chipIcon
|
||||
set(value) {
|
||||
view.chipIcon = value
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ object KotatsuColors {
|
||||
val hue = (manga.id.absoluteValue % 360).toFloat()
|
||||
ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
|
||||
} else {
|
||||
context.getThemeColor(R.attr.colorSurface)
|
||||
context.getThemeColor(R.attr.colorOutline)
|
||||
}
|
||||
val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh)
|
||||
return MaterialColors.harmonize(color, backgroundColor)
|
||||
|
||||
@@ -27,6 +27,7 @@ import android.provider.Settings
|
||||
import android.view.View
|
||||
import android.view.ViewPropertyAnimator
|
||||
import android.view.Window
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.annotation.IntegerRes
|
||||
import androidx.annotation.WorkerThread
|
||||
@@ -235,3 +236,13 @@ fun Context.ensureRamAtLeast(requiredSize: Long) {
|
||||
throw IllegalStateException("Not enough free memory")
|
||||
}
|
||||
}
|
||||
|
||||
fun WebView.configureForParser(userAgentOverride: String?) = with(settings) {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
mediaPlaybackRequiresUserGesture = false
|
||||
databaseEnabled = true
|
||||
if (userAgentOverride != null) {
|
||||
userAgentString = userAgentOverride
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import android.provider.OpenableColumns
|
||||
import androidx.core.database.getStringOrNull
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.fs.FileSequence
|
||||
import java.io.File
|
||||
@@ -53,7 +52,7 @@ fun File.getStorageName(context: Context): String = runCatching {
|
||||
|
||||
fun Uri.toFileOrNull() = if (scheme == URI_SCHEME_FILE) path?.let(::File) else null
|
||||
|
||||
suspend fun File.deleteAwait() = withContext(Dispatchers.IO) {
|
||||
suspend fun File.deleteAwait() = runInterruptible(Dispatchers.IO) {
|
||||
delete() || deleteRecursively()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okhttp3.internal.isSensitiveHeader
|
||||
import okio.IOException
|
||||
import org.json.JSONObject
|
||||
import org.jsoup.HttpStatusException
|
||||
@@ -59,3 +61,16 @@ fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
|
||||
c.httpOnly()
|
||||
}
|
||||
}
|
||||
|
||||
fun String.sanitizeHeaderValue(): String {
|
||||
return if (all(Char::isValidForHeaderValue)) {
|
||||
this // fast path
|
||||
} else {
|
||||
filter(Char::isValidForHeaderValue)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Char.isValidForHeaderValue(): Boolean {
|
||||
// from okhttp3.Headers$Companion.checkValue
|
||||
return this == '\t' || this in '\u0020'..'\u007e'
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
|
||||
import org.koitharu.kotatsu.core.exceptions.SyncApiException
|
||||
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
|
||||
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED
|
||||
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED
|
||||
@@ -56,6 +57,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
|
||||
|
||||
is WrongPasswordException -> resources.getString(R.string.wrong_password)
|
||||
is NotFoundException -> resources.getString(R.string.not_found_404)
|
||||
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
|
||||
|
||||
is HttpException -> getHttpDisplayMessage(response.code, resources)
|
||||
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
|
||||
@@ -98,14 +100,16 @@ fun Throwable.isReportable(): Boolean {
|
||||
return this is Error || this.javaClass in reportableExceptions
|
||||
}
|
||||
|
||||
fun Throwable.isNetworkError(): Boolean {
|
||||
return this is UnknownHostException || this is SocketTimeoutException
|
||||
}
|
||||
|
||||
fun Throwable.report() {
|
||||
val exception = CaughtException(this, "${javaClass.simpleName}($message)")
|
||||
exception.sendWithAcra()
|
||||
}
|
||||
|
||||
private val reportableExceptions = arraySetOf<Class<*>>(
|
||||
ParseException::class.java,
|
||||
JSONException::class.java,
|
||||
RuntimeException::class.java,
|
||||
IllegalStateException::class.java,
|
||||
IllegalArgumentException::class.java,
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.progressindicator.BaseProgressIndicator
|
||||
import com.google.android.material.slider.Slider
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
@@ -169,3 +170,11 @@ fun MaterialButton.setProgressIcon() {
|
||||
icon = progressDrawable
|
||||
progressDrawable.start()
|
||||
}
|
||||
|
||||
fun Chip.setProgressIcon() {
|
||||
val progressDrawable = CircularProgressDrawable(context)
|
||||
progressDrawable.strokeWidth = resources.resolveDp(2f)
|
||||
progressDrawable.setColorSchemeColors(currentTextColor)
|
||||
chipIcon = progressDrawable
|
||||
progressDrawable.start()
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ fun MangaDetails.mapChapters(
|
||||
newCount: Int,
|
||||
branch: String?,
|
||||
bookmarks: List<Bookmark>,
|
||||
isGrid: Boolean,
|
||||
): List<ChapterListItem> {
|
||||
val remoteChapters = chapters[branch].orEmpty()
|
||||
val localChapters = local?.manga?.getChapters(branch).orEmpty()
|
||||
@@ -47,6 +48,7 @@ fun MangaDetails.mapChapters(
|
||||
isNew = isUnread && result.size >= newFrom,
|
||||
isDownloaded = local != null,
|
||||
isBookmarked = chapter.id in bookmarked,
|
||||
isGrid = isGrid,
|
||||
)
|
||||
}
|
||||
if (!localMap.isNullOrEmpty()) {
|
||||
@@ -60,6 +62,7 @@ fun MangaDetails.mapChapters(
|
||||
isNew = false,
|
||||
isDownloaded = !isLocal,
|
||||
isBookmarked = chapter.id in bookmarked,
|
||||
isGrid = isGrid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,9 @@ class ChaptersMenuProvider(
|
||||
}
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
menu.findItem(R.id.action_reversed)?.isChecked = viewModel.isChaptersReversed.value == true
|
||||
menu.findItem(R.id.action_search)?.isVisible = viewModel.isChaptersEmpty.value == false
|
||||
menu.findItem(R.id.action_reversed)?.isChecked = viewModel.isChaptersReversed.value == true
|
||||
menu.findItem(R.id.action_grid_view)?.isChecked = viewModel.isChaptersInGridView.value == true
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||
@@ -37,6 +38,10 @@ class ChaptersMenuProvider(
|
||||
viewModel.setChaptersReversed(!menuItem.isChecked)
|
||||
true
|
||||
}
|
||||
R.id.action_grid_view-> {
|
||||
viewModel.setChaptersInGridView(!menuItem.isChecked)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
@@ -125,19 +124,7 @@ class DetailsActivity :
|
||||
viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated)
|
||||
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
|
||||
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
|
||||
viewModel.onError.observeEvent(
|
||||
this,
|
||||
SnackbarErrorObserver(
|
||||
host = viewBinding.containerDetails,
|
||||
fragment = null,
|
||||
resolver = exceptionResolver,
|
||||
onResolved = { isResolved ->
|
||||
if (isResolved) {
|
||||
viewModel.reload()
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
viewModel.onError.observeEvent(this, DetailsErrorObserver(this, viewModel, exceptionResolver))
|
||||
viewModel.onActionDone.observeEvent(
|
||||
this,
|
||||
ReversibleActionObserver(viewBinding.containerDetails, viewBinding.layoutBottom),
|
||||
@@ -153,7 +140,7 @@ class DetailsActivity :
|
||||
viewModel.isChaptersEmpty.observe(this, chaptersMenuInvalidator)
|
||||
val menuInvalidator = MenuInvalidator(this)
|
||||
viewModel.favouriteCategories.observe(this, menuInvalidator)
|
||||
viewModel.isStatsEnabled.observe(this, menuInvalidator)
|
||||
viewModel.isStatsAvailable.observe(this, menuInvalidator)
|
||||
viewModel.remoteManga.observe(this, menuInvalidator)
|
||||
viewModel.branches.observe(this) {
|
||||
viewBinding.buttonDropdown.isVisible = it.size > 1
|
||||
@@ -389,12 +376,13 @@ class DetailsActivity :
|
||||
}
|
||||
|
||||
private fun initPager() {
|
||||
val adapter = DetailsPagerAdapter(this)
|
||||
val adapter = DetailsPagerAdapter(this, settings)
|
||||
viewBinding.pager.recyclerView?.isNestedScrollingEnabled = false
|
||||
viewBinding.pager.offscreenPageLimit = 1
|
||||
viewBinding.pager.adapter = adapter
|
||||
TabLayoutMediator(viewBinding.tabs, viewBinding.pager, adapter).attach()
|
||||
viewBinding.pager.setCurrentItem(settings.defaultDetailsTab, false)
|
||||
viewBinding.tabs.isVisible = adapter.itemCount > 1
|
||||
}
|
||||
|
||||
private fun showBottomSheet(isVisible: Boolean) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ErrorObserver
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.isNetworkError
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||
|
||||
class DetailsErrorObserver(
|
||||
override val activity: DetailsActivity,
|
||||
private val viewModel: DetailsViewModel,
|
||||
resolver: ExceptionResolver?,
|
||||
) : ErrorObserver(
|
||||
activity.viewBinding.containerDetails, null, resolver,
|
||||
{ isResolved ->
|
||||
if (isResolved) {
|
||||
viewModel.reload()
|
||||
}
|
||||
},
|
||||
) {
|
||||
|
||||
override suspend fun emit(value: Throwable) {
|
||||
val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
|
||||
if (value is NotFoundException || value is UnsupportedSourceException) {
|
||||
snackbar.duration = Snackbar.LENGTH_INDEFINITE
|
||||
}
|
||||
when {
|
||||
canResolve(value) -> {
|
||||
snackbar.setAction(ExceptionResolver.getResolveStringId(value)) {
|
||||
resolve(value)
|
||||
}
|
||||
}
|
||||
|
||||
value is ParseException -> {
|
||||
val fm = fragmentManager
|
||||
if (fm != null) {
|
||||
snackbar.setAction(R.string.details) {
|
||||
ErrorDetailsDialog.show(fm, value, value.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value.isNetworkError() -> {
|
||||
snackbar.setAction(R.string.try_again) {
|
||||
viewModel.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
snackbar.show()
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
@@ -41,10 +42,11 @@ class DetailsMenuProvider(
|
||||
menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != MangaSource.LOCAL
|
||||
menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL
|
||||
menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL
|
||||
menu.findItem(R.id.action_alternatives).isVisible = manga?.source != MangaSource.LOCAL
|
||||
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
|
||||
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
|
||||
menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null
|
||||
menu.findItem(R.id.action_stats).isVisible = viewModel.isStatsEnabled.value
|
||||
menu.findItem(R.id.action_stats).isVisible = viewModel.isStatsAvailable.value
|
||||
menu.findItem(R.id.action_favourite).setIcon(
|
||||
if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline,
|
||||
)
|
||||
@@ -103,6 +105,12 @@ class DetailsMenuProvider(
|
||||
}
|
||||
}
|
||||
|
||||
R.id.action_alternatives -> {
|
||||
viewModel.manga.value?.let {
|
||||
activity.startActivity(AlternativesActivity.newIntent(activity, it))
|
||||
}
|
||||
}
|
||||
|
||||
R.id.action_stats -> {
|
||||
viewModel.manga.value?.let {
|
||||
MangaStatsSheet.show(activity.supportFragmentManager, it)
|
||||
|
||||
@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.plus
|
||||
@@ -35,6 +36,7 @@ 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.computeSize
|
||||
import org.koitharu.kotatsu.core.util.ext.onEachWhile
|
||||
import org.koitharu.kotatsu.core.util.ext.requireValue
|
||||
@@ -61,6 +63,7 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
|
||||
import org.koitharu.kotatsu.stats.data.StatsRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -79,6 +82,7 @@ class DetailsViewModel @Inject constructor(
|
||||
private val detailsLoadUseCase: DetailsLoadUseCase,
|
||||
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
||||
private val readingTimeUseCase: ReadingTimeUseCase,
|
||||
private val statsRepository: StatsRepository,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val intent = MangaIntent(savedStateHandle)
|
||||
@@ -100,9 +104,8 @@ class DetailsViewModel @Inject constructor(
|
||||
val favouriteCategories = interactor.observeIsFavourite(mangaId)
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
|
||||
|
||||
val isStatsEnabled = settings.observeAsStateFlow(viewModelScope + Dispatchers.Default, AppSettings.KEY_STATS_ENABLED) {
|
||||
isStatsEnabled
|
||||
}
|
||||
val isStatsAvailable = statsRepository.observeHasStats(mangaId)
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
|
||||
|
||||
val remoteManga = MutableStateFlow<Manga?>(null)
|
||||
|
||||
@@ -120,7 +123,13 @@ class DetailsViewModel @Inject constructor(
|
||||
val isChaptersReversed = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_REVERSE_CHAPTERS,
|
||||
valueProducer = { chaptersReverse },
|
||||
valueProducer = { isChaptersReverse },
|
||||
)
|
||||
|
||||
val isChaptersInGridView = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_GRID_VIEW_CHAPTERS,
|
||||
valueProducer = { isChaptersGridView },
|
||||
)
|
||||
|
||||
val historyInfo: StateFlow<HistoryInfo> = combine(
|
||||
@@ -143,6 +152,7 @@ class DetailsViewModel @Inject constructor(
|
||||
val localSize = details
|
||||
.map { it?.local }
|
||||
.distinctUntilChanged()
|
||||
.combine(localStorageChanges.onStart { emit(null) }) { x, _ -> x }
|
||||
.map { local ->
|
||||
if (local != null) {
|
||||
runCatchingCancellable {
|
||||
@@ -204,12 +214,14 @@ class DetailsViewModel @Inject constructor(
|
||||
selectedBranch,
|
||||
newChaptersCount,
|
||||
bookmarks,
|
||||
) { manga, history, branch, news, bookmarks ->
|
||||
isChaptersInGridView,
|
||||
) { manga, history, branch, news, bookmarks, grid ->
|
||||
manga?.mapChapters(
|
||||
history,
|
||||
news,
|
||||
branch,
|
||||
bookmarks,
|
||||
grid,
|
||||
).orEmpty()
|
||||
},
|
||||
isChaptersReversed,
|
||||
@@ -279,7 +291,11 @@ class DetailsViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun setChaptersReversed(newValue: Boolean) {
|
||||
settings.chaptersReverse = newValue
|
||||
settings.isChaptersReverse = newValue
|
||||
}
|
||||
|
||||
fun setChaptersInGridView(newValue: Boolean) {
|
||||
settings.isChaptersGridView = newValue
|
||||
}
|
||||
|
||||
fun setSelectedBranch(branch: String?) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.koitharu.kotatsu.details.ui.adapter
|
||||
|
||||
import android.graphics.Typeface
|
||||
import androidx.core.view.isVisible
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.core.model.formatNumber
|
||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
|
||||
import org.koitharu.kotatsu.databinding.ItemChapterGridBinding
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
fun chapterGridItemAD(
|
||||
clickListener: OnListItemClickListener<ChapterListItem>,
|
||||
) = adapterDelegateViewBinding<ChapterListItem, ListModel, ItemChapterGridBinding>(
|
||||
viewBinding = { inflater, parent -> ItemChapterGridBinding.inflate(inflater, parent, false) },
|
||||
on = { item, _, _ -> item is ChapterListItem && item.isGrid },
|
||||
) {
|
||||
|
||||
val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||
itemView.setOnClickListener(eventListener)
|
||||
itemView.setOnLongClickListener(eventListener)
|
||||
|
||||
bind { payloads ->
|
||||
if (payloads.isEmpty()) {
|
||||
binding.textViewTitle.text = item.chapter.formatNumber() ?: "?"
|
||||
}
|
||||
binding.imageViewNew.isVisible = item.isNew
|
||||
binding.imageViewCurrent.isVisible = item.isCurrent
|
||||
binding.imageViewBookmarked.isVisible = item.isBookmarked
|
||||
binding.imageViewDownloaded.isVisible = item.isDownloaded
|
||||
|
||||
when {
|
||||
item.isCurrent -> {
|
||||
binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary))
|
||||
binding.textViewTitle.typeface = Typeface.DEFAULT_BOLD
|
||||
}
|
||||
|
||||
item.isUnread -> {
|
||||
binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary))
|
||||
binding.textViewTitle.typeface = Typeface.DEFAULT
|
||||
}
|
||||
|
||||
else -> {
|
||||
binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorHint))
|
||||
binding.textViewTitle.typeface = Typeface.DEFAULT
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ import com.google.android.material.R as MR
|
||||
fun chapterListItemAD(
|
||||
clickListener: OnListItemClickListener<ChapterListItem>,
|
||||
) = adapterDelegateViewBinding<ChapterListItem, ListModel, ItemChapterBinding>(
|
||||
{ inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) },
|
||||
viewBinding = { inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) },
|
||||
on = { item, _, _ -> item is ChapterListItem && !item.isGrid }
|
||||
) {
|
||||
|
||||
val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||
|
||||
@@ -10,12 +10,13 @@ import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
class ChaptersAdapter(
|
||||
onItemClickListener: OnListItemClickListener<ChapterListItem>,
|
||||
private val onItemClickListener: OnListItemClickListener<ChapterListItem>,
|
||||
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
||||
|
||||
init {
|
||||
addDelegate(ListItemType.CHAPTER, chapterListItemAD(onItemClickListener))
|
||||
addDelegate(ListItemType.HEADER, listHeaderAD(null))
|
||||
addDelegate(ListItemType.CHAPTER_LIST, chapterListItemAD(onItemClickListener))
|
||||
addDelegate(ListItemType.CHAPTER_GRID, chapterGridItemAD(onItemClickListener))
|
||||
}
|
||||
|
||||
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
||||
|
||||
@@ -6,8 +6,11 @@ import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.view.View
|
||||
import androidx.cardview.widget.CardView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
|
||||
import org.koitharu.kotatsu.core.util.ext.getItem
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
@@ -18,6 +21,14 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor
|
||||
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val radius = context.resources.getDimension(materialR.dimen.abc_control_corner_material)
|
||||
private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
|
||||
private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.chapter_check_offset)
|
||||
private val iconSize = context.resources.getDimensionPixelOffset(R.dimen.chapter_check_size)
|
||||
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
|
||||
private val fillColor = ColorUtils.setAlphaComponent(
|
||||
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
|
||||
0x74,
|
||||
)
|
||||
|
||||
init {
|
||||
paint.color = ColorUtils.setAlphaComponent(
|
||||
@@ -25,6 +36,12 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor
|
||||
98,
|
||||
)
|
||||
paint.style = Paint.Style.FILL
|
||||
hasBackground = true
|
||||
hasForeground = true
|
||||
isIncludeDecorAndMargins = false
|
||||
|
||||
paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width)
|
||||
checkIcon?.setTint(strokeColor)
|
||||
}
|
||||
|
||||
override fun getItemId(parent: RecyclerView, child: View): Long {
|
||||
@@ -40,6 +57,37 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor
|
||||
bounds: RectF,
|
||||
state: RecyclerView.State,
|
||||
) {
|
||||
if (child is CardView) {
|
||||
return
|
||||
}
|
||||
canvas.drawRoundRect(bounds, radius, radius, paint)
|
||||
}
|
||||
|
||||
override fun onDrawForeground(
|
||||
canvas: Canvas,
|
||||
parent: RecyclerView,
|
||||
child: View,
|
||||
bounds: RectF,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
if (child !is CardView) {
|
||||
return
|
||||
}
|
||||
val radius = child.radius
|
||||
paint.color = fillColor
|
||||
paint.style = Paint.Style.FILL
|
||||
canvas.drawRoundRect(bounds, radius, radius, paint)
|
||||
paint.color = strokeColor
|
||||
paint.style = Paint.Style.STROKE
|
||||
canvas.drawRoundRect(bounds, radius, radius, paint)
|
||||
checkIcon?.run {
|
||||
setBounds(
|
||||
(bounds.right - iconSize - iconOffset).toInt(),
|
||||
(bounds.top + iconOffset).toInt(),
|
||||
(bounds.right - iconOffset).toInt(),
|
||||
(bounds.top + iconOffset + iconSize).toInt(),
|
||||
)
|
||||
draw(canvas)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@ data class ChapterListItem(
|
||||
val isNew: Boolean
|
||||
get() = hasFlag(FLAG_NEW)
|
||||
|
||||
val isGrid: Boolean
|
||||
get() = hasFlag(FLAG_GRID)
|
||||
|
||||
private fun buildDescription(): String {
|
||||
val joiner = StringJoiner(" • ")
|
||||
chapter.formatNumber()?.let {
|
||||
@@ -90,5 +93,6 @@ data class ChapterListItem(
|
||||
const val FLAG_NEW = 8
|
||||
const val FLAG_BOOKMARKED = 16
|
||||
const val FLAG_DOWNLOADED = 32
|
||||
const val FLAG_GRID = 64
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.details.ui.model
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_BOOKMARKED
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_GRID
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_NEW
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_UNREAD
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
@@ -13,6 +14,7 @@ fun MangaChapter.toListItem(
|
||||
isNew: Boolean,
|
||||
isDownloaded: Boolean,
|
||||
isBookmarked: Boolean,
|
||||
isGrid: Boolean,
|
||||
): ChapterListItem {
|
||||
var flags = 0
|
||||
if (isCurrent) flags = flags or FLAG_CURRENT
|
||||
@@ -20,6 +22,7 @@ fun MangaChapter.toListItem(
|
||||
if (isNew) flags = flags or FLAG_NEW
|
||||
if (isBookmarked) flags = flags or FLAG_BOOKMARKED
|
||||
if (isDownloaded) flags = flags or FLAG_DOWNLOADED
|
||||
if (isGrid) flags = flags or FLAG_GRID
|
||||
return ChapterListItem(
|
||||
chapter = this,
|
||||
flags = flags,
|
||||
|
||||
@@ -6,13 +6,19 @@ import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.details.ui.pager.chapters.ChaptersFragment
|
||||
import org.koitharu.kotatsu.details.ui.pager.pages.PagesFragment
|
||||
|
||||
class DetailsPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity),
|
||||
class DetailsPagerAdapter(
|
||||
activity: FragmentActivity,
|
||||
settings: AppSettings,
|
||||
) : FragmentStateAdapter(activity),
|
||||
TabLayoutMediator.TabConfigurationStrategy {
|
||||
|
||||
override fun getItemCount(): Int = 2
|
||||
val isPagesTabEnabled = settings.isPagesTabEnabled
|
||||
|
||||
override fun getItemCount(): Int = if (isPagesTabEnabled) 2 else 1
|
||||
|
||||
override fun createFragment(position: Int): Fragment = when (position) {
|
||||
0 -> ChaptersFragment()
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager.chapters
|
||||
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class ChapterGridSpanHelper private constructor() : View.OnLayoutChangeListener {
|
||||
|
||||
override fun onLayoutChange(
|
||||
v: View?,
|
||||
left: Int,
|
||||
top: Int,
|
||||
right: Int,
|
||||
bottom: Int,
|
||||
oldLeft: Int,
|
||||
oldTop: Int,
|
||||
oldRight: Int,
|
||||
oldBottom: Int
|
||||
) {
|
||||
val rv = v as? RecyclerView ?: return
|
||||
if (rv.width > 0) {
|
||||
apply(rv)
|
||||
}
|
||||
}
|
||||
|
||||
private fun apply(rv: RecyclerView) {
|
||||
(rv.layoutManager as? GridLayoutManager)?.spanCount = getSpanCount(rv)
|
||||
}
|
||||
|
||||
class SpanSizeLookup(
|
||||
private val recyclerView: RecyclerView
|
||||
) : GridLayoutManager.SpanSizeLookup() {
|
||||
|
||||
override fun getSpanSize(position: Int): Int {
|
||||
return when (recyclerView.adapter?.getItemViewType(position)) {
|
||||
ListItemType.CHAPTER_LIST.ordinal, // for smooth transition
|
||||
ListItemType.HEADER.ordinal -> getTotalSpans()
|
||||
|
||||
else -> 1
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTotalSpans() = (recyclerView.layoutManager as? GridLayoutManager)?.spanCount ?: 1
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun attach(view: RecyclerView) {
|
||||
val helper = ChapterGridSpanHelper()
|
||||
view.addOnLayoutChangeListener(helper)
|
||||
helper.apply(view)
|
||||
}
|
||||
|
||||
fun getSpanCount(view: RecyclerView): Int {
|
||||
val cellWidth = view.resources.getDimension(R.dimen.chapter_grid_width)
|
||||
val estimatedCount = (view.width / cellWidth).roundToInt()
|
||||
return estimatedCount.coerceAtLeast(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
@@ -62,12 +64,22 @@ class ChaptersFragment :
|
||||
registryOwner = this,
|
||||
callback = this,
|
||||
)
|
||||
viewModel.isChaptersInGridView.observe(viewLifecycleOwner) { chaptersInGridView ->
|
||||
binding.recyclerViewChapters.layoutManager = if (chaptersInGridView) {
|
||||
GridLayoutManager(context, ChapterGridSpanHelper.getSpanCount(binding.recyclerViewChapters)).apply {
|
||||
spanSizeLookup = ChapterGridSpanHelper.SpanSizeLookup(binding.recyclerViewChapters)
|
||||
}
|
||||
} else {
|
||||
LinearLayoutManager(context)
|
||||
}
|
||||
}
|
||||
with(binding.recyclerViewChapters) {
|
||||
addItemDecoration(TypedListSpacingDecoration(context, true))
|
||||
checkNotNull(selectionController).attachToRecyclerView(this)
|
||||
setHasFixedSize(true)
|
||||
isNestedScrollingEnabled = false
|
||||
adapter = chaptersAdapter
|
||||
ChapterGridSpanHelper.attach(this)
|
||||
}
|
||||
viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged)
|
||||
viewModel.chapters
|
||||
|
||||
@@ -59,7 +59,7 @@ fun downloadItemAD(
|
||||
}
|
||||
}
|
||||
val chaptersAdapter = BaseListAdapter<DownloadChapter>()
|
||||
.addDelegate(ListItemType.CHAPTER, downloadChapterAD())
|
||||
.addDelegate(ListItemType.CHAPTER_LIST, downloadChapterAD())
|
||||
|
||||
binding.recyclerViewChapters.addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL))
|
||||
binding.recyclerViewChapters.adapter = chaptersAdapter
|
||||
|
||||
@@ -52,6 +52,10 @@ class MangaSourcesRepository @Inject constructor(
|
||||
return dao.findAllDisabled().toSources(settings.isNsfwContentDisabled, null)
|
||||
}
|
||||
|
||||
fun observeIsEnabled(source: MangaSource): Flow<Boolean> {
|
||||
return dao.observeIsEnabled(source.name)
|
||||
}
|
||||
|
||||
fun observeEnabledSourcesCount(): Flow<Int> {
|
||||
return combine(
|
||||
observeIsNsfwDisabled(),
|
||||
|
||||
@@ -118,6 +118,9 @@ abstract class FavouritesDao {
|
||||
@Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id")
|
||||
abstract suspend fun find(id: Long): FavouriteManga?
|
||||
|
||||
@Query("SELECT * FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0")
|
||||
abstract suspend fun findAllRaw(mangaId: Long): List<FavouriteEntity>
|
||||
|
||||
@Transaction
|
||||
@Deprecated("Ignores order")
|
||||
@Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id")
|
||||
@@ -190,11 +193,14 @@ abstract class FavouritesDao {
|
||||
private fun getOrderBy(sortOrder: ListSortOrder) = when (sortOrder) {
|
||||
ListSortOrder.RATING -> "manga.rating DESC"
|
||||
ListSortOrder.NEWEST -> "favourites.created_at DESC"
|
||||
ListSortOrder.OLDEST -> "favourites.created_at ASC"
|
||||
ListSortOrder.ALPHABETIC -> "manga.title ASC"
|
||||
ListSortOrder.ALPHABETIC_REVERSE -> "manga.title DESC"
|
||||
ListSortOrder.NEW_CHAPTERS -> "IFNULL((SELECT chapters_new FROM tracks WHERE tracks.manga_id = manga.manga_id), 0) DESC"
|
||||
ListSortOrder.PROGRESS -> "IFNULL((SELECT percent FROM history WHERE history.manga_id = manga.manga_id), 0) DESC"
|
||||
ListSortOrder.UNREAD -> "IFNULL((SELECT percent FROM history WHERE history.manga_id = manga.manga_id), 0) ASC"
|
||||
ListSortOrder.LAST_READ -> "IFNULL((SELECT updated_at FROM history WHERE history.manga_id = manga.manga_id), 0) DESC"
|
||||
ListSortOrder.LONG_AGO_READ -> "IFNULL((SELECT updated_at FROM history WHERE history.manga_id = manga.manga_id), 0) ASC"
|
||||
|
||||
else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
|
||||
}
|
||||
|
||||
@@ -178,6 +178,7 @@ class FavouriteCategoriesActivity :
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("")
|
||||
companion object {
|
||||
|
||||
fun newIntent(context: Context) = Intent(context, FavouriteCategoriesActivity::class.java)
|
||||
|
||||
@@ -36,8 +36,11 @@ abstract class HistoryDao {
|
||||
fun observeAll(order: ListSortOrder): Flow<List<HistoryWithManga>> {
|
||||
val orderBy = when (order) {
|
||||
ListSortOrder.LAST_READ -> "history.updated_at DESC"
|
||||
ListSortOrder.LONG_AGO_READ -> "history.updated_at ASC"
|
||||
ListSortOrder.NEWEST -> "history.created_at DESC"
|
||||
ListSortOrder.OLDEST -> "history.created_at ASC"
|
||||
ListSortOrder.PROGRESS -> "history.percent DESC"
|
||||
ListSortOrder.UNREAD -> "history.percent ASC"
|
||||
ListSortOrder.ALPHABETIC -> "manga.title"
|
||||
ListSortOrder.ALPHABETIC_REVERSE -> "manga.title DESC"
|
||||
ListSortOrder.NEW_CHAPTERS -> "IFNULL((SELECT chapters_new FROM tracks WHERE tracks.manga_id = manga.manga_id), 0) DESC"
|
||||
|
||||
@@ -178,8 +178,13 @@ class HistoryListViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun MangaHistory.header(order: ListSortOrder): ListHeader? = when (order) {
|
||||
ListSortOrder.LAST_READ -> ListHeader(calculateTimeAgo(updatedAt))
|
||||
ListSortOrder.LAST_READ,
|
||||
ListSortOrder.LONG_AGO_READ -> ListHeader(calculateTimeAgo(updatedAt))
|
||||
|
||||
ListSortOrder.OLDEST,
|
||||
ListSortOrder.NEWEST -> ListHeader(calculateTimeAgo(createdAt))
|
||||
|
||||
ListSortOrder.UNREAD,
|
||||
ListSortOrder.PROGRESS -> ListHeader(
|
||||
when (percent) {
|
||||
1f -> R.string.status_completed
|
||||
|
||||
@@ -10,21 +10,45 @@ enum class ListSortOrder(
|
||||
) {
|
||||
|
||||
NEWEST(R.string.order_added),
|
||||
OLDEST(R.string.order_oldest),
|
||||
PROGRESS(R.string.progress),
|
||||
UNREAD(R.string.unread),
|
||||
ALPHABETIC(R.string.by_name),
|
||||
ALPHABETIC_REVERSE(R.string.by_name_reverse),
|
||||
RATING(R.string.by_rating),
|
||||
RELEVANCE(R.string.by_relevance),
|
||||
NEW_CHAPTERS(R.string.new_chapters),
|
||||
LAST_READ(R.string.last_read),
|
||||
LONG_AGO_READ(R.string.long_ago_read),
|
||||
;
|
||||
|
||||
fun isGroupingSupported() = this == LAST_READ || this == NEWEST || this == PROGRESS
|
||||
|
||||
companion object {
|
||||
|
||||
val HISTORY: Set<ListSortOrder> = EnumSet.of(LAST_READ, NEWEST, PROGRESS, ALPHABETIC, ALPHABETIC_REVERSE, NEW_CHAPTERS)
|
||||
val FAVORITES: Set<ListSortOrder> = EnumSet.of(ALPHABETIC, ALPHABETIC_REVERSE, NEWEST, RATING, NEW_CHAPTERS, PROGRESS, LAST_READ)
|
||||
val HISTORY: Set<ListSortOrder> = EnumSet.of(
|
||||
LAST_READ,
|
||||
LONG_AGO_READ,
|
||||
NEWEST,
|
||||
OLDEST,
|
||||
PROGRESS,
|
||||
UNREAD,
|
||||
ALPHABETIC,
|
||||
ALPHABETIC_REVERSE,
|
||||
NEW_CHAPTERS,
|
||||
)
|
||||
val FAVORITES: Set<ListSortOrder> = EnumSet.of(
|
||||
ALPHABETIC,
|
||||
ALPHABETIC_REVERSE,
|
||||
NEWEST,
|
||||
OLDEST,
|
||||
RATING,
|
||||
NEW_CHAPTERS,
|
||||
PROGRESS,
|
||||
UNREAD,
|
||||
LAST_READ,
|
||||
LONG_AGO_READ,
|
||||
)
|
||||
val SUGGESTIONS: Set<ListSortOrder> = EnumSet.of(RELEVANCE)
|
||||
|
||||
operator fun invoke(value: String, fallback: ListSortOrder) = entries.find(value) ?: fallback
|
||||
|
||||
@@ -20,6 +20,5 @@ fun errorFooterAD(
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.exception.getDisplayMessage(context.resources)
|
||||
binding.imageViewIcon.setImageResource(item.icon)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,5 +29,6 @@ enum class ListItemType {
|
||||
CATEGORY_LARGE,
|
||||
MANGA_SCROBBLING,
|
||||
NAV_ITEM,
|
||||
CHAPTER,
|
||||
CHAPTER_LIST,
|
||||
CHAPTER_GRID,
|
||||
}
|
||||
|
||||
@@ -62,10 +62,12 @@ class TypedListSpacingDecoration(
|
||||
ListItemType.MANGA_NESTED_GROUP,
|
||||
ListItemType.CATEGORY_LARGE,
|
||||
ListItemType.NAV_ITEM,
|
||||
ListItemType.CHAPTER,
|
||||
ListItemType.CHAPTER_LIST,
|
||||
null,
|
||||
-> outRect.set(0)
|
||||
|
||||
ListItemType.CHAPTER_GRID -> outRect.set(spacingSmall)
|
||||
|
||||
ListItemType.TIP -> outRect.set(0) // TODO
|
||||
}
|
||||
if (addHorizontalPadding && !itemType.isEdgeToEdge()) {
|
||||
@@ -83,5 +85,6 @@ class TypedListSpacingDecoration(
|
||||
private fun ListItemType?.isEdgeToEdge() = this == ListItemType.MANGA_NESTED_GROUP
|
||||
|| this == ListItemType.FILTER_SORT
|
||||
|| this == ListItemType.FILTER_TAG
|
||||
|| this == ListItemType.CHAPTER
|
||||
|| this == ListItemType.CHAPTER_LIST
|
||||
|| this == ListItemType.CHAPTER_GRID
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import androidx.annotation.DrawableRes
|
||||
|
||||
data class ErrorFooter(
|
||||
val exception: Throwable,
|
||||
@DrawableRes val icon: Int
|
||||
) : ListModel {
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
|
||||
@@ -83,5 +83,4 @@ fun Throwable.toErrorState(canRetry: Boolean = true) = ErrorState(
|
||||
|
||||
fun Throwable.toErrorFooter() = ErrorFooter(
|
||||
exception = this,
|
||||
icon = R.drawable.ic_alert_outline,
|
||||
)
|
||||
|
||||
@@ -106,7 +106,7 @@ class MangaIndex(source: String?) {
|
||||
}
|
||||
|
||||
fun removeChapter(id: Long): Boolean {
|
||||
return json.getJSONObject("chapters").remove(id.toString()) != null
|
||||
return json.has("chapters") && json.getJSONObject("chapters").remove(id.toString()) != null
|
||||
}
|
||||
|
||||
fun getChapterFileName(chapterId: Long): String? {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.koitharu.kotatsu.local.data.output
|
||||
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -9,6 +11,7 @@ import org.koitharu.kotatsu.core.util.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
|
||||
import org.koitharu.kotatsu.core.zip.ZipOutput
|
||||
import org.koitharu.kotatsu.local.data.MangaIndex
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaDirInput
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
|
||||
@@ -46,22 +49,23 @@ class LocalMangaDirOutput(
|
||||
flushIndex()
|
||||
}
|
||||
|
||||
override suspend fun addPage(chapter: IndexedValue<MangaChapter>, file: File, pageNumber: Int, ext: String) = mutex.withLock {
|
||||
val output = chaptersOutput.getOrPut(chapter.value) {
|
||||
ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP))
|
||||
}
|
||||
val name = buildString {
|
||||
append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber))
|
||||
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||
append('.')
|
||||
append(ext)
|
||||
override suspend fun addPage(chapter: IndexedValue<MangaChapter>, file: File, pageNumber: Int, ext: String) =
|
||||
mutex.withLock {
|
||||
val output = chaptersOutput.getOrPut(chapter.value) {
|
||||
ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP))
|
||||
}
|
||||
val name = buildString {
|
||||
append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber))
|
||||
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||
append('.')
|
||||
append(ext)
|
||||
}
|
||||
}
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
output.put(name, file)
|
||||
}
|
||||
index.addChapter(chapter, chapterFileName(chapter))
|
||||
}
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
output.put(name, file)
|
||||
}
|
||||
index.addChapter(chapter, chapterFileName(chapter))
|
||||
}
|
||||
|
||||
override suspend fun flushChapter(chapter: MangaChapter): Boolean = mutex.withLock {
|
||||
val output = chaptersOutput.remove(chapter) ?: return@withLock false
|
||||
@@ -90,13 +94,24 @@ class LocalMangaDirOutput(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteChapter(chapterId: Long) = mutex.withLock {
|
||||
val chapter = checkNotNull(index.getMangaInfo()?.chapters?.withIndex()) {
|
||||
suspend fun deleteChapters(ids: Set<Long>) = mutex.withLock {
|
||||
val chapters = checkNotNull((index.getMangaInfo() ?: LocalMangaDirInput(rootFile).getManga().manga).chapters) {
|
||||
"No chapters found"
|
||||
}.find { x -> x.value.id == chapterId } ?: error("Chapter not found")
|
||||
val chapterDir = File(rootFile, chapterFileName(chapter))
|
||||
chapterDir.deleteAwait()
|
||||
index.removeChapter(chapterId)
|
||||
}.withIndex()
|
||||
val victimsIds = ids.toMutableSet()
|
||||
for (chapter in chapters) {
|
||||
if (!victimsIds.remove(chapter.value.id)) {
|
||||
continue
|
||||
}
|
||||
val chapterFile = index.getChapterFileName(chapter.value.id)?.let {
|
||||
File(rootFile, it)
|
||||
} ?: chapter.value.url.toUri().toFile()
|
||||
chapterFile.deleteAwait()
|
||||
index.removeChapter(chapter.value.id)
|
||||
}
|
||||
check(victimsIds.isEmpty()) {
|
||||
"${victimsIds.size} of ${ids.size} chapters was not removed: not found"
|
||||
}
|
||||
}
|
||||
|
||||
fun setIndex(newIndex: MangaIndex) {
|
||||
|
||||
@@ -25,9 +25,7 @@ class LocalMangaUtil(
|
||||
}
|
||||
|
||||
is LocalMangaDirOutput -> {
|
||||
for (id in ids) {
|
||||
output.deleteChapter(id)
|
||||
}
|
||||
output.deleteChapters(ids)
|
||||
output.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package org.koitharu.kotatsu.local.domain
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.buffer
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.fold
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.core.model.findById
|
||||
import org.koitharu.kotatsu.core.model.ids
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
|
||||
class DeleteReadChaptersUseCase @Inject constructor(
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
private val historyRepository: HistoryRepository,
|
||||
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(manga: Manga): Int {
|
||||
val localManga = if (manga.isLocal) {
|
||||
LocalManga(manga)
|
||||
} else {
|
||||
checkNotNull(localMangaRepository.findSavedManga(manga)) { "Cannot find local manga" }
|
||||
}
|
||||
val task = getDeletionTask(localManga) ?: return 0
|
||||
localMangaRepository.deleteChapters(task.manga.manga, task.chaptersIds)
|
||||
emitUpdate(localManga)
|
||||
return task.chaptersIds.size
|
||||
}
|
||||
|
||||
suspend operator fun invoke(): Int {
|
||||
val list = localMangaRepository.getList(0, null)
|
||||
if (list.isEmpty()) {
|
||||
return 0
|
||||
}
|
||||
return channelFlow {
|
||||
for (manga in list) {
|
||||
launch(Dispatchers.Default) {
|
||||
val task = runCatchingCancellable {
|
||||
getDeletionTask(LocalManga(manga))
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
if (task != null) {
|
||||
send(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.buffer().map {
|
||||
runCatchingCancellable {
|
||||
localMangaRepository.deleteChapters(it.manga.manga, it.chaptersIds)
|
||||
emitUpdate(it.manga)
|
||||
it.chaptersIds.size
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrDefault(0)
|
||||
}.fold(0) { acc, x -> acc + x }
|
||||
}
|
||||
|
||||
private suspend fun getDeletionTask(manga: LocalManga): DeletionTask? {
|
||||
val history = historyRepository.getOne(manga.manga) ?: return null
|
||||
val chapters = manga.manga.chapters ?: localMangaRepository.getDetails(manga.manga).chapters
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
val branch = (chapters.findById(history.chapterId) ?: return null).branch
|
||||
val filteredChapters = manga.manga.getChapters(branch)?.takeWhile { it.id != history.chapterId }
|
||||
return if (filteredChapters.isNullOrEmpty()) {
|
||||
null
|
||||
} else {
|
||||
DeletionTask(
|
||||
manga = manga,
|
||||
chaptersIds = filteredChapters.ids(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun emitUpdate(subject: LocalManga) {
|
||||
val updated = localMangaRepository.getDetails(subject.manga)
|
||||
localStorageChanges.emit(subject.copy(manga = updated))
|
||||
}
|
||||
|
||||
private class DeletionTask(
|
||||
val manga: LocalManga,
|
||||
val chaptersIds: Set<Long>,
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
@@ -45,10 +46,13 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
|
||||
val manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga ?: return
|
||||
val chaptersIds = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet() ?: return
|
||||
startForeground()
|
||||
val mangaWithChapters = localMangaRepository.getDetails(manga)
|
||||
localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds)
|
||||
localStorageChanges.emit(LocalManga(localMangaRepository.getDetails(manga)))
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
try {
|
||||
val mangaWithChapters = localMangaRepository.getDetails(manga)
|
||||
localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds)
|
||||
localStorageChanges.emit(LocalManga(localMangaRepository.getDetails(manga)))
|
||||
} finally {
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(startId: Int, error: Throwable) {
|
||||
@@ -60,6 +64,7 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
|
||||
.setContentText(error.getDisplayMessage(resources))
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(ErrorReporterReceiver.getPendingIntent(this, error))
|
||||
.build()
|
||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.notify(NOTIFICATION_ID + startId, notification)
|
||||
|
||||
@@ -13,18 +13,25 @@ import androidx.work.await
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.domain.DeleteReadChaptersUseCase
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@HiltWorker
|
||||
class LocalStorageCleanupWorker @AssistedInject constructor(
|
||||
@Assisted appContext: Context,
|
||||
@Assisted params: WorkerParameters,
|
||||
private val settings: AppSettings,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
private val dataRepository: MangaDataRepository,
|
||||
private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
if (settings.isAutoLocalChaptersCleanupEnabled) {
|
||||
deleteReadChaptersUseCase.invoke()
|
||||
}
|
||||
return if (localMangaRepository.cleanup()) {
|
||||
dataRepository.cleanupLocalManga()
|
||||
Result.success()
|
||||
@@ -44,7 +51,7 @@ class LocalStorageCleanupWorker @AssistedInject constructor(
|
||||
val request = OneTimeWorkRequestBuilder<ImportWorker>()
|
||||
.setConstraints(constraints)
|
||||
.addTag(TAG)
|
||||
.setBackoffCriteria(BackoffPolicy.LINEAR, 1, TimeUnit.MINUTES)
|
||||
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.KEEP, request).await()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package org.koitharu.kotatsu.local.ui.info
|
||||
import android.content.res.ColorStateList
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
@@ -14,9 +16,11 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView
|
||||
import org.koitharu.kotatsu.core.util.KotatsuColors
|
||||
import org.koitharu.kotatsu.core.util.FileSize
|
||||
import org.koitharu.kotatsu.core.util.KotatsuColors
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.setProgressIcon
|
||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.DialogLocalInfoBinding
|
||||
@@ -24,14 +28,12 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class LocalInfoDialog : AlertDialogFragment<DialogLocalInfoBinding>() {
|
||||
class LocalInfoDialog : AlertDialogFragment<DialogLocalInfoBinding>(), View.OnClickListener {
|
||||
|
||||
private val viewModel: LocalInfoViewModel by viewModels()
|
||||
|
||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||
return super.onBuildDialog(builder)
|
||||
.setTitle(R.string.saved_manga)
|
||||
.setNegativeButton(R.string.close, null)
|
||||
return super.onBuildDialog(builder).setTitle(R.string.saved_manga).setNegativeButton(R.string.close, null)
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogLocalInfoBinding {
|
||||
@@ -43,13 +45,44 @@ class LocalInfoDialog : AlertDialogFragment<DialogLocalInfoBinding>() {
|
||||
viewModel.path.observe(this) {
|
||||
binding.textViewPath.text = it
|
||||
}
|
||||
combine(viewModel.size, viewModel.availableSize, ::Pair).observe(this) {
|
||||
binding.chipCleanup.setOnClickListener(this)
|
||||
combine(viewModel.size, viewModel.availableSize, ::Pair).observe(viewLifecycleOwner) {
|
||||
if (it.first >= 0 && it.second >= 0) {
|
||||
setSegments(it.first, it.second)
|
||||
} else {
|
||||
binding.barView.animateSegments(emptyList())
|
||||
}
|
||||
}
|
||||
viewModel.onCleanedUp.observeEvent(viewLifecycleOwner, ::onCleanedUp)
|
||||
viewModel.isCleaningUp.observe(viewLifecycleOwner) { loading ->
|
||||
binding.chipCleanup.isClickable = !loading
|
||||
dialog?.setCancelable(!loading)
|
||||
if (loading) {
|
||||
binding.chipCleanup.setProgressIcon()
|
||||
} else {
|
||||
binding.chipCleanup.setChipIconResource(R.drawable.ic_delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.chip_cleanup -> viewModel.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onCleanedUp(result: Pair<Int, Long>) {
|
||||
val c = context ?: return
|
||||
val text = if (result.first == 0 && result.second == 0L) {
|
||||
c.getString(R.string.no_chapters_deleted)
|
||||
} else {
|
||||
c.getString(
|
||||
R.string.chapters_deleted_pattern,
|
||||
c.resources.getQuantityString(R.plurals.chapters, result.first, result.first),
|
||||
FileSize.BYTES.format(c, result.second),
|
||||
)
|
||||
}
|
||||
Toast.makeText(c, text, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun setSegments(size: Long, available: Long) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.local.ui.info
|
||||
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -8,12 +7,14 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.computeSize
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.local.domain.DeleteReadChaptersUseCase
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -21,21 +22,42 @@ class LocalInfoViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
private val storageManager: LocalStorageManager,
|
||||
private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val manga = savedStateHandle.require<ParcelableManga>(LocalInfoDialog.ARG_MANGA).manga
|
||||
|
||||
val isCleaningUp = MutableStateFlow(false)
|
||||
val onCleanedUp = MutableEventFlow<Pair<Int, Long>>()
|
||||
|
||||
val path = MutableStateFlow<String?>(null)
|
||||
val size = MutableStateFlow(-1L)
|
||||
val availableSize = MutableStateFlow(-1L)
|
||||
|
||||
init {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val file = manga.url.toUri().toFileOrNull() ?: localMangaRepository.findSavedManga(manga)?.file
|
||||
requireNotNull(file)
|
||||
path.value = file.path
|
||||
size.value = file.computeSize()
|
||||
availableSize.value = storageManager.computeAvailableSize()
|
||||
computeSize()
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
try {
|
||||
isCleaningUp.value = true
|
||||
val oldSize = size.value
|
||||
val chaptersCount = deleteReadChaptersUseCase.invoke(manga)
|
||||
computeSize().join()
|
||||
val newSize = size.value
|
||||
onCleanedUp.call(chaptersCount to oldSize - newSize)
|
||||
} finally {
|
||||
isCleaningUp.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeSize() = launchLoadingJob(Dispatchers.Default) {
|
||||
val file = manga.url.toUri().toFileOrNull() ?: localMangaRepository.findSavedManga(manga)?.file
|
||||
requireNotNull(file)
|
||||
path.value = file.path
|
||||
size.value = file.computeSize()
|
||||
availableSize.value = storageManager.computeAvailableSize()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ class ChapterPages private constructor(private val pages: ArrayDeque<ReaderPage>
|
||||
val chaptersSize: Int
|
||||
get() = indices.size()
|
||||
|
||||
@Synchronized
|
||||
fun removeFirst() {
|
||||
val chapterId = pages.first().chapterId
|
||||
indices.remove(chapterId)
|
||||
@@ -25,6 +26,7 @@ class ChapterPages private constructor(private val pages: ArrayDeque<ReaderPage>
|
||||
shiftIndices(delta)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeLast() {
|
||||
val chapterId = pages.last().chapterId
|
||||
indices.remove(chapterId)
|
||||
@@ -33,17 +35,28 @@ class ChapterPages private constructor(private val pages: ArrayDeque<ReaderPage>
|
||||
}
|
||||
}
|
||||
|
||||
fun addLast(id: Long, newPages: List<ReaderPage>) {
|
||||
@Synchronized
|
||||
fun addLast(id: Long, newPages: List<ReaderPage>): Boolean {
|
||||
if (id in indices) {
|
||||
return false
|
||||
}
|
||||
indices.put(id, pages.size until (pages.size + newPages.size))
|
||||
pages.addAll(newPages)
|
||||
return true
|
||||
}
|
||||
|
||||
fun addFirst(id: Long, newPages: List<ReaderPage>) {
|
||||
@Synchronized
|
||||
fun addFirst(id: Long, newPages: List<ReaderPage>): Boolean {
|
||||
if (id in indices) {
|
||||
return false
|
||||
}
|
||||
shiftIndices(newPages.size)
|
||||
indices.put(id, newPages.indices)
|
||||
pages.addAll(0, newPages)
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
indices.clear()
|
||||
pages.clear()
|
||||
@@ -58,7 +71,7 @@ class ChapterPages private constructor(private val pages: ArrayDeque<ReaderPage>
|
||||
return pages.subList(range.first, range.last + 1)
|
||||
}
|
||||
|
||||
operator fun contains(chapterId: Long) = indices.contains(chapterId)
|
||||
operator fun contains(chapterId: Long) = chapterId in indices
|
||||
|
||||
private fun shiftIndices(delta: Int) {
|
||||
for (i in 0 until indices.size()) {
|
||||
|
||||
@@ -6,6 +6,8 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||
@@ -19,6 +21,7 @@ import org.koitharu.kotatsu.databinding.SheetChaptersBinding
|
||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
|
||||
import org.koitharu.kotatsu.details.ui.mapChapters
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.details.ui.pager.chapters.ChapterGridSpanHelper
|
||||
import org.koitharu.kotatsu.details.ui.withVolumeHeaders
|
||||
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
@@ -64,6 +67,7 @@ class ChaptersSheet : BaseAdaptiveSheet<SheetChaptersBinding>(),
|
||||
newCount = 0,
|
||||
branch = currentChapter?.branch,
|
||||
bookmarks = listOf(),
|
||||
isGrid = settings.isChaptersGridView,
|
||||
).withVolumeHeaders(binding.root.context)
|
||||
if (chapters.isEmpty()) {
|
||||
dismissAllowingStateLoss()
|
||||
@@ -87,6 +91,14 @@ class ChaptersSheet : BaseAdaptiveSheet<SheetChaptersBinding>(),
|
||||
adapter.items = chapters
|
||||
}
|
||||
}
|
||||
ChapterGridSpanHelper.attach(binding.recyclerView)
|
||||
binding.recyclerView.layoutManager = if (settings.isChaptersGridView) {
|
||||
GridLayoutManager(context, ChapterGridSpanHelper.getSpanCount(binding.recyclerView)).apply {
|
||||
spanSizeLookup = ChapterGridSpanHelper.SpanSizeLookup(binding.recyclerView)
|
||||
}
|
||||
} else {
|
||||
LinearLayoutManager(context)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(item: ChapterListItem, view: View) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.preference.SwitchPreferenceCompat
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import org.koitharu.kotatsu.settings.utils.AutoCompleteTextViewPreference
|
||||
import org.koitharu.kotatsu.settings.utils.EditTextBindListener
|
||||
import org.koitharu.kotatsu.settings.utils.EditTextDefaultSummaryProvider
|
||||
@@ -42,7 +43,13 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang
|
||||
}
|
||||
|
||||
is ConfigKey.UserAgent -> {
|
||||
EditTextPreference(requireContext()).apply {
|
||||
AutoCompleteTextViewPreference(requireContext()).apply {
|
||||
entries = arrayOf(
|
||||
UserAgents.FIREFOX_MOBILE,
|
||||
UserAgents.CHROME_MOBILE,
|
||||
UserAgents.FIREFOX_DESKTOP,
|
||||
UserAgents.CHROME_DESKTOP,
|
||||
)
|
||||
summaryProvider = EditTextDefaultSummaryProvider(key.defaultValue)
|
||||
setOnBindEditTextListener(
|
||||
EditTextBindListener(
|
||||
@@ -62,9 +69,18 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang
|
||||
setTitle(R.string.show_suspicious_content)
|
||||
}
|
||||
}
|
||||
|
||||
is ConfigKey.SplitByTranslations -> {
|
||||
SwitchPreferenceCompat(requireContext()).apply {
|
||||
setDefaultValue(key.defaultValue)
|
||||
setTitle(R.string.split_by_translations)
|
||||
setSummary(R.string.split_by_translations_summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
preference.isIconSpaceReserved = false
|
||||
preference.key = key.key
|
||||
preference.order = 10
|
||||
screen.addPreference(preference)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
@@ -18,7 +19,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SourceSettingsFragment : BasePreferenceFragment(0) {
|
||||
class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenceChangeListener {
|
||||
|
||||
private val viewModel: SourceSettingsViewModel by viewModels()
|
||||
private val exceptionResolver = ExceptionResolver(this)
|
||||
@@ -34,6 +35,9 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
|
||||
addPreferencesFromResource(R.xml.pref_source)
|
||||
addPreferencesFromRepository(viewModel.repository)
|
||||
|
||||
findPreference<SwitchPreferenceCompat>(KEY_ENABLE)?.run {
|
||||
setOnPreferenceChangeListener(this@SourceSettingsFragment)
|
||||
}
|
||||
findPreference<Preference>(KEY_AUTH)?.run {
|
||||
val authProvider = viewModel.repository.getAuthProvider()
|
||||
isVisible = authProvider != null
|
||||
@@ -59,6 +63,9 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
|
||||
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
|
||||
findPreference<Preference>(KEY_AUTH)?.isEnabled = !isLoading
|
||||
}
|
||||
viewModel.isEnabled.observe(viewLifecycleOwner) { enabled ->
|
||||
findPreference<SwitchPreferenceCompat>(KEY_ENABLE)?.isChecked = enabled
|
||||
}
|
||||
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView))
|
||||
}
|
||||
|
||||
@@ -68,6 +75,7 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
|
||||
startActivity(SourceAuthActivity.newIntent(preference.context, viewModel.source))
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_COOKIES_CLEAR -> {
|
||||
viewModel.clearCookies()
|
||||
true
|
||||
@@ -77,9 +85,18 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean {
|
||||
when (preference.key) {
|
||||
KEY_ENABLE -> viewModel.setEnabled(newValue == true)
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val KEY_AUTH = "auth"
|
||||
private const val KEY_ENABLE = "enable"
|
||||
|
||||
const val EXTRA_SOURCE = "source"
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.settings.sources
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -10,11 +11,14 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
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.require
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import javax.inject.Inject
|
||||
@@ -24,19 +28,33 @@ class SourceSettingsViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val cookieJar: MutableCookieJar,
|
||||
) : BaseViewModel() {
|
||||
private val mangaSourcesRepository: MangaSourcesRepository,
|
||||
) : BaseViewModel(), SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
val source = savedStateHandle.require<MangaSource>(SourceSettingsFragment.EXTRA_SOURCE)
|
||||
val repository = mangaRepositoryFactory.create(source) as RemoteMangaRepository
|
||||
|
||||
val onActionDone = MutableEventFlow<ReversibleAction>()
|
||||
val username = MutableStateFlow<String?>(null)
|
||||
val isEnabled = mangaSourcesRepository.observeIsEnabled(source)
|
||||
private var usernameLoadJob: Job? = null
|
||||
|
||||
init {
|
||||
repository.getConfig().subscribe(this)
|
||||
loadUsername()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
repository.getConfig().unsubscribe(this)
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
if (key != SourceSettings.KEY_SLOWDOWN && key != SourceSettings.KEY_SORT_ORDER) {
|
||||
repository.invalidateCache()
|
||||
}
|
||||
}
|
||||
|
||||
fun onResume() {
|
||||
if (usernameLoadJob?.isActive != true) {
|
||||
loadUsername()
|
||||
@@ -55,6 +73,12 @@ class SourceSettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setEnabled(value: Boolean) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
mangaSourcesRepository.setSourceEnabled(source, value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadUsername() {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
try {
|
||||
|
||||
@@ -18,15 +18,16 @@ import org.koitharu.kotatsu.browser.BrowserCallback
|
||||
import org.koitharu.kotatsu.browser.BrowserClient
|
||||
import org.koitharu.kotatsu.browser.ProgressChromeClient
|
||||
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -64,12 +65,7 @@ class SourceAuthActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallba
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||
}
|
||||
with(viewBinding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
userAgentString = UserAgents.CHROME_MOBILE
|
||||
}
|
||||
viewBinding.webView.configureForParser(repository.headers[CommonHeaders.USER_AGENT])
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||
|
||||
@@ -15,7 +15,7 @@ class SourcesCatalogAdapter(
|
||||
) : BaseListAdapter<SourceCatalogItem>(), FastScroller.SectionIndexer {
|
||||
|
||||
init {
|
||||
addDelegate(ListItemType.CHAPTER, sourceCatalogItemSourceAD(coil, lifecycleOwner, listener))
|
||||
addDelegate(ListItemType.CHAPTER_LIST, sourceCatalogItemSourceAD(coil, lifecycleOwner, listener))
|
||||
addDelegate(ListItemType.HINT_EMPTY, sourceCatalogItemHintAD(coil, lifecycleOwner))
|
||||
}
|
||||
|
||||
|
||||
@@ -13,13 +13,16 @@ import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
||||
import org.koitharu.kotatsu.databinding.ActivityMangaDirectoriesBinding
|
||||
import org.koitharu.kotatsu.settings.storage.DirectoryDiffCallback
|
||||
import org.koitharu.kotatsu.settings.storage.DirectoryModel
|
||||
@@ -42,7 +45,11 @@ class MangaDirectoriesActivity : BaseActivity<ActivityMangaDirectoriesBinding>()
|
||||
) {
|
||||
if (it) {
|
||||
viewModel.updateList()
|
||||
pickFileTreeLauncher.launch(null)
|
||||
if (!pickFileTreeLauncher.tryLaunch(null)) {
|
||||
Snackbar.make(
|
||||
viewBinding.recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +75,11 @@ class MangaDirectoriesActivity : BaseActivity<ActivityMangaDirectoriesBinding>()
|
||||
}
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
permissionRequestLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
if (!permissionRequestLauncher.tryLaunch(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
|
||||
Snackbar.make(
|
||||
viewBinding.recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
|
||||
@@ -94,6 +94,7 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
|
||||
}
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
|
||||
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView))
|
||||
viewModel.onChaptersCleanedUp.observeEvent(viewLifecycleOwner, ::onChaptersCleanedUp)
|
||||
settings.subscribe(this)
|
||||
}
|
||||
|
||||
@@ -129,6 +130,11 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_CHAPTERS_CLEAR -> {
|
||||
cleanupChapters()
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_UPDATES_FEED_CLEAR -> {
|
||||
viewModel.clearUpdatesFeed()
|
||||
true
|
||||
@@ -191,6 +197,21 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
|
||||
}
|
||||
}
|
||||
|
||||
private fun onChaptersCleanedUp(result: Pair<Int, Long>) {
|
||||
val c = context ?: return
|
||||
val text = if (result.first == 0 && result.second == 0L) {
|
||||
c.getString(R.string.no_chapters_deleted)
|
||||
} else {
|
||||
c.getString(
|
||||
R.string.chapters_deleted_pattern,
|
||||
c.resources.getQuantityString(R.plurals.chapters, result.first, result.first),
|
||||
FileSize.BYTES.format(c, result.second),
|
||||
)
|
||||
}
|
||||
Snackbar.make(listView, text, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
|
||||
private fun Preference.bindBytesSizeSummary(stateFlow: StateFlow<Long>) {
|
||||
stateFlow.observe(viewLifecycleOwner) { size ->
|
||||
summary = if (size < 0) {
|
||||
@@ -235,6 +256,16 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun cleanupChapters() {
|
||||
MaterialAlertDialogBuilder(context ?: return)
|
||||
.setTitle(R.string.delete_read_chapters)
|
||||
.setMessage(R.string.delete_read_chapters_prompt)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.delete) { _, _ ->
|
||||
viewModel.cleanupChapters()
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun postRestart() {
|
||||
view?.postDelayed(400) {
|
||||
activityRecreationHandle.recreateAll()
|
||||
|
||||
@@ -18,8 +18,10 @@ 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.firstNotNull
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.local.domain.DeleteReadChaptersUseCase
|
||||
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import java.util.EnumMap
|
||||
@@ -33,6 +35,7 @@ class UserDataSettingsViewModel @Inject constructor(
|
||||
private val trackingRepository: TrackingRepository,
|
||||
private val cookieJar: MutableCookieJar,
|
||||
private val settings: AppSettings,
|
||||
private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val onActionDone = MutableEventFlow<ReversibleAction>()
|
||||
@@ -44,6 +47,8 @@ class UserDataSettingsViewModel @Inject constructor(
|
||||
val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java)
|
||||
val storageUsage = MutableStateFlow<StorageUsage?>(null)
|
||||
|
||||
val onChaptersCleanedUp = MutableEventFlow<Pair<Int, Long>>()
|
||||
|
||||
val periodicalBackupFrequency = settings.observeAsFlow(
|
||||
key = AppSettings.KEY_BACKUP_PERIODICAL_ENABLED,
|
||||
valueProducer = { isPeriodicalBackupEnabled },
|
||||
@@ -133,9 +138,24 @@ class UserDataSettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadStorageUsage() {
|
||||
fun cleanupChapters() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
try {
|
||||
loadingKeys.update { it + AppSettings.KEY_CHAPTERS_CLEAR }
|
||||
val oldSize = storageUsage.firstNotNull().savedManga.bytes
|
||||
val chaptersCount = deleteReadChaptersUseCase.invoke()
|
||||
loadStorageUsage().join()
|
||||
val newSize = storageUsage.firstNotNull().savedManga.bytes
|
||||
onChaptersCleanedUp.call(chaptersCount to oldSize - newSize)
|
||||
} finally {
|
||||
loadingKeys.update { it - AppSettings.KEY_CHAPTERS_CLEAR }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadStorageUsage(): Job {
|
||||
val prevJob = storageUsageJob
|
||||
storageUsageJob = launchJob(Dispatchers.Default) {
|
||||
return launchJob(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
val pagesCacheSize = storageManager.computeCacheSize(CacheDir.PAGES)
|
||||
val otherCacheSize = storageManager.computeCacheSize() - pagesCacheSize
|
||||
@@ -160,6 +180,8 @@ class UserDataSettingsViewModel @Inject constructor(
|
||||
percent = (availableSpace.toDouble() / totalBytes).toFloat(),
|
||||
),
|
||||
)
|
||||
}.also {
|
||||
storageUsageJob = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ abstract class StatsDao {
|
||||
@Query("DELETE FROM stats")
|
||||
abstract suspend fun clear()
|
||||
|
||||
@Query("SELECT COUNT(*) FROM stats WHERE manga_id = :mangaId")
|
||||
abstract fun observeRowCount(mangaId: Long): Flow<Int>
|
||||
|
||||
@Upsert
|
||||
abstract suspend fun upsert(entity: StatsEntity)
|
||||
|
||||
|
||||
@@ -3,8 +3,17 @@ package org.koitharu.kotatsu.stats.data
|
||||
import androidx.collection.LongIntMap
|
||||
import androidx.collection.MutableLongIntMap
|
||||
import androidx.room.withTransaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
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 org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.combine
|
||||
import org.koitharu.kotatsu.stats.domain.StatsPeriod
|
||||
import org.koitharu.kotatsu.stats.domain.StatsRecord
|
||||
import java.util.NavigableMap
|
||||
@@ -13,6 +22,7 @@ import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
class StatsRepository @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
private val db: MangaDatabase,
|
||||
) {
|
||||
|
||||
@@ -71,4 +81,14 @@ class StatsRepository @Inject constructor(
|
||||
suspend fun clearStats() {
|
||||
db.getStatsDao().clear()
|
||||
}
|
||||
|
||||
fun observeHasStats(mangaId: Long): Flow<Boolean> = settings.observeAsFlow(AppSettings.KEY_STATS_ENABLED) {
|
||||
isStatsEnabled
|
||||
}.flatMapLatest { isEnabled ->
|
||||
if (isEnabled) {
|
||||
db.getStatsDao().observeRowCount(mangaId).map { it > 0 }
|
||||
} else {
|
||||
flowOf(false)
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:alpha="0.2" android:color="?attr/colorPrimaryInverse" />
|
||||
<item android:alpha="0.27" android:color="?attr/colorOnSurface" />
|
||||
</selector>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- https://stackoverflow.com/questions/54685474/theme-attributes-in-color-selector-for-api-22 -->
|
||||
<item android:alpha="0.2" android:color="@color/kotatsu_inversePrimary" />
|
||||
<item android:alpha="0.27" android:color="@color/kotatsu_onSurface" />
|
||||
</selector>
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M4,4C2.89,4 2,4.89 2,6L2,18C2,19.11 2.89,20 4,20L9,20L9,18L8,18L6,18L4,18L4,6L6,6L8,6L9,6L9,4L4,4zM15,4L15,6L16,6L18,6L20,6L20,18L18,18L16,18L15,18L15,20L20,20C21.1,20 22,19.11 22,18L22,6C22,4.89 21.1,4 20,4L15,4z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m11,22.43v-3h2v3z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m13,1.57v3h-2v-3h2"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m11,17.43v-3h2v3h-2"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m11,9.57v-3h2v3h-2"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m14.5,15v-2h-7v-2h7V9l4,3 -4,3"/>
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M4,4C2.89,4 2,4.89 2,6L2,18C2,19.11 2.89,20 4,20L9,20L9,18L8,18L6,18L4,18L4,6L6,6L8,6L9,6L9,4L4,4zM15,4L15,6L16,6L18,6L20,6L20,18L18,18L16,18L15,18L15,20L20,20C21.1,20 22,19.11 22,18L22,6C22,4.89 21.1,4 20,4L15,4z" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m11,22.43v-3h2v3z" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m13,1.57v3h-2v-3h2" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m11,17.43v-3h2v3h-2" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m11,9.57v-3h2v3h-2" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m14.5,15v-2h-7v-2h7V9l4,3 -4,3" />
|
||||
</vector>
|
||||
|
||||
11
app/src/main/res/drawable/ic_replace.xml
Normal file
11
app/src/main/res/drawable/ic_replace.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<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="#000"
|
||||
android:pathData="M14,3L12,1H4A2,2 0 0,0 2,3V15A2,2 0 0,0 4,17H11V19L15,16L11,13V15H4V3H14M21,10V21A2,2 0 0,1 19,23H8A2,2 0 0,1 6,21V19H8V21H19V12H14V7H8V13H6V7A2,2 0 0,1 8,5H16L21,10Z" />
|
||||
</vector>
|
||||
46
app/src/main/res/layout/activity_alternatives.xml
Normal file
46
app/src/main/res/layout/activity_alternatives.xml
Normal file
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/appbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<com.google.android.material.appbar.CollapsingToolbarLayout
|
||||
android:id="@+id/collapsingToolbarLayout"
|
||||
style="?attr/collapsingToolbarLayoutMediumStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/collapsingToolbarLayoutMediumSize"
|
||||
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap"
|
||||
app:toolbarId="@id/toolbar">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:layout_collapseMode="pin"
|
||||
tools:title="@string/alternatives" />
|
||||
|
||||
</com.google.android.material.appbar.CollapsingToolbarLayout>
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/list_spacing_normal"
|
||||
android:scrollbars="vertical"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
|
||||
tools:listitem="@layout/item_manga_alternative" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -6,7 +6,8 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="?dialogPreferredPadding">
|
||||
android:paddingHorizontal="?dialogPreferredPadding"
|
||||
android:paddingTop="?dialogPreferredPadding">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_path_label"
|
||||
@@ -51,4 +52,13 @@
|
||||
app:drawableStartCompat="@drawable/bg_rounded_square"
|
||||
app:drawableTint="?colorSecondaryContainer" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chip_cleanup"
|
||||
style="@style/Widget.Kotatsu.Chip.Assist"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/delete_read_chapters"
|
||||
app:chipIcon="@drawable/ic_delete" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
84
app/src/main/res/layout/item_chapter_grid.xml
Normal file
84
app/src/main/res/layout/item_chapter_grid.xml
Normal file
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
style="?materialCardViewOutlinedStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:layout_width="@dimen/chapter_grid_width">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:gravity="center"
|
||||
android:singleLine="true"
|
||||
android:textAlignment="center"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
app:layout_constraintDimensionRatio="1:1"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="150"
|
||||
tools:textColor="?android:textColorPrimary" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView_bookmarked"
|
||||
android:layout_width="14dp"
|
||||
android:layout_height="14dp"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:layout_marginBottom="6dp"
|
||||
android:contentDescription="@string/bookmarks"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="@id/textView_title"
|
||||
app:layout_constraintEnd_toEndOf="@id/textView_title"
|
||||
app:srcCompat="@drawable/ic_bookmark"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView_downloaded"
|
||||
android:layout_width="14dp"
|
||||
android:layout_height="14dp"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:layout_marginBottom="6dp"
|
||||
android:contentDescription="@string/downloaded"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="@id/textView_title"
|
||||
app:layout_constraintEnd_toStartOf="@id/imageView_bookmarked"
|
||||
app:srcCompat="@drawable/ic_save_ok"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView_new"
|
||||
android:layout_width="8dp"
|
||||
android:layout_height="8dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:contentDescription="@string/new_chapters"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toEndOf="@id/textView_title"
|
||||
app:layout_constraintTop_toTopOf="@id/textView_title"
|
||||
app:srcCompat="@drawable/ic_new"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView_current"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginStart="6dp"
|
||||
android:layout_marginTop="6dp"
|
||||
android:contentDescription="@string/new_chapters"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintStart_toStartOf="@id/textView_title"
|
||||
app:layout_constraintTop_toTopOf="@id/textView_title"
|
||||
app:srcCompat="@drawable/ic_current_chapter"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@@ -91,7 +91,7 @@
|
||||
android:ellipsize="end"
|
||||
android:paddingTop="4dp"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||
android:textColor="?android:attr/textColorPrimary"
|
||||
tools:text="@string/theme_name_miku" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/margin_small"
|
||||
android:textAppearance="?attr/textAppearanceTitleLarge"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/icon"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/margin_small"
|
||||
android:textAppearance="?attr/textAppearanceTitleLarge"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/icon"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
|
||||
@@ -3,38 +3,25 @@
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/list_footer_height_outer"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?selectableItemBackground"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:minHeight="@dimen/list_footer_height_outer"
|
||||
android:orientation="vertical"
|
||||
android:padding="4dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView_icon"
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:contentDescription="@null"
|
||||
tools:src="@drawable/ic_alert_outline" />
|
||||
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||
tools:text="@string/too_many_requests_message" />
|
||||
|
||||
<LinearLayout
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:orientation="vertical">
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="@string/tap_to_try_again"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
tools:text="@string/error_occurred" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/tap_to_try_again"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
android:drawablePadding="12dp"
|
||||
android:gravity="center_horizontal"
|
||||
android:textAlignment="center"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
app:drawableTopCompat="@drawable/ic_error_large"
|
||||
tools:text="@tools:sample/lorem[6]" />
|
||||
|
||||
@@ -29,4 +29,4 @@
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/try_again" />
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/list_footer_height_outer">
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="@dimen/list_footer_height_outer">
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/progressBar"
|
||||
|
||||
94
app/src/main/res/layout/item_manga_alternative.xml
Normal file
94
app/src/main/res/layout/item_manga_alternative.xml
Normal file
@@ -0,0 +1,94 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="16dp">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/imageView_cover"
|
||||
android:layout_width="98dp"
|
||||
android:layout_height="0dp"
|
||||
android:background="?colorSurfaceContainer"
|
||||
android:scaleType="centerCrop"
|
||||
app:layout_constraintDimensionRatio="13:18"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover"
|
||||
tools:src="@tools:sample/backgrounds/scenic" />
|
||||
|
||||
<org.koitharu.kotatsu.history.ui.util.ReadingProgressView
|
||||
android:id="@+id/progressView"
|
||||
android:layout_width="@dimen/card_indicator_size"
|
||||
android:layout_height="@dimen/card_indicator_size"
|
||||
android:layout_margin="@dimen/card_indicator_offset"
|
||||
app:layout_constraintBottom_toBottomOf="@id/imageView_cover"
|
||||
app:layout_constraintEnd_toEndOf="@id/imageView_cover" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="2"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="@tools:sample/lorem" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_subtitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:maxLines="2"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
||||
app:layout_constraintTop_toBottomOf="@+id/textView_title"
|
||||
tools:text="@tools:sample/lorem/random" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chip_source"
|
||||
style="@style/Widget.Kotatsu.Chip.Assist"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="12dp"
|
||||
app:layout_constrainedWidth="true"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0"
|
||||
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
||||
app:layout_constraintTop_toBottomOf="@+id/textView_subtitle"
|
||||
tools:text="Mangadex" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/button_migrate"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:text="@string/migrate"
|
||||
app:icon="@drawable/ic_replace"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/chip_source"
|
||||
app:layout_constraintVertical_bias="1" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@@ -1,36 +0,0 @@
|
||||
<?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:gravity="center_vertical"
|
||||
android:minHeight="58dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView_handle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingHorizontal="?listPreferredItemPaddingStart"
|
||||
android:scaleType="center"
|
||||
android:src="@drawable/ic_reorder_handle" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="end"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
tools:text="@tools:sample/lorem[15]" />
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/switch_toggle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="?listPreferredItemPaddingEnd" />
|
||||
|
||||
</LinearLayout>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user