Merge remote-tracking branch 'origin/devel' into devel

master
Mac135135 2 years ago
commit 6e5519419d

1
.gitignore vendored

@ -25,3 +25,4 @@
.externalNativeBuild .externalNativeBuild
.cxx .cxx
/.idea/deviceManager.xml /.idea/deviceManager.xml
/.kotlin/

1
.idea/.gitignore vendored

@ -2,3 +2,4 @@
/shelf/ /shelf/
/workspace.xml /workspace.xml
/migrations.xml /migrations.xml
/runConfigurations.xml

@ -15,9 +15,9 @@ android {
defaultConfig { defaultConfig {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 35
versionCode = 651 versionCode = 657
versionName = '7.3' versionName = '7.4'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {
@ -82,23 +82,23 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:74b8aaa94e') { implementation('com.github.KotatsuApp:kotatsu-parsers:a9fc534ea7') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24' implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.10-RC'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0-RC'
implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.core:core-ktx:1.13.1' implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.activity:activity-ktx:1.9.0' implementation 'androidx.activity:activity-ktx:1.9.1'
implementation 'androidx.fragment:fragment-ktx:1.8.1' implementation 'androidx.fragment:fragment-ktx:1.8.2'
implementation 'androidx.transition:transition-ktx:1.5.0' implementation 'androidx.transition:transition-ktx:1.5.1'
implementation 'androidx.collection:collection-ktx:1.4.0' implementation 'androidx.collection:collection-ktx:1.4.2'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4'
implementation 'androidx.lifecycle:lifecycle-service:2.8.3' implementation 'androidx.lifecycle:lifecycle-service:2.8.4'
implementation 'androidx.lifecycle:lifecycle-process:2.8.3' implementation 'androidx.lifecycle:lifecycle-process:2.8.4'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'androidx.recyclerview:recyclerview:1.3.2'
@ -106,7 +106,7 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.12.0' implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.3' implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.4'
implementation 'androidx.webkit:webkit:1.11.0' implementation 'androidx.webkit:webkit:1.11.0'
implementation 'androidx.work:work-runtime:2.9.0' implementation 'androidx.work:work-runtime:2.9.0'
@ -134,8 +134,8 @@ dependencies {
implementation 'androidx.hilt:hilt-work:1.2.0' implementation 'androidx.hilt:hilt-work:1.2.0'
kapt 'androidx.hilt:hilt-compiler:1.2.0' kapt 'androidx.hilt:hilt-compiler:1.2.0'
implementation 'io.coil-kt:coil-base:2.6.0' implementation 'io.coil-kt:coil-base:2.7.0'
implementation 'io.coil-kt:coil-svg:2.6.0' implementation 'io.coil-kt:coil-svg:2.7.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:882bc0620c' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:882bc0620c'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2' implementation 'io.noties.markwon:core:4.6.2'

@ -20,6 +20,10 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES"/>
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" /> android:maxSdkVersion="29" />

@ -12,6 +12,7 @@ import org.koitharu.kotatsu.core.util.ext.almostEquals
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
@ -57,7 +58,7 @@ class AlternativesUseCase @Inject constructor(
} }
private suspend fun getSources(ref: MangaSource): List<MangaSource> { private suspend fun getSources(ref: MangaSource): List<MangaSource> {
val result = ArrayList<MangaSource>(MangaSource.entries.size - 2) val result = ArrayList<MangaSource>(MangaParserSource.entries.size - 2)
result.addAll(sourcesRepository.getEnabledSources()) result.addAll(sourcesRepository.getEnabledSources())
result.sortByDescending { it.priority(ref) } result.sortByDescending { it.priority(ref) }
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) }) result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
@ -78,8 +79,10 @@ class AlternativesUseCase @Inject constructor(
private fun MangaSource.priority(ref: MangaSource): Int { private fun MangaSource.priority(ref: MangaSource): Int {
var res = 0 var res = 0
if (locale == ref.locale) res += 2 if (this is MangaParserSource && ref is MangaParserSource) {
if (contentType == ref.contentType) res++ if (locale == ref.locale) res += 2
if (contentType == ref.contentType) res++
}
return res return res
} }
} }

@ -18,178 +18,178 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import javax.inject.Inject import javax.inject.Inject
class MigrateUseCase class MigrateUseCase
@Inject @Inject
constructor( constructor(
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val mangaDataRepository: MangaDataRepository, private val mangaDataRepository: MangaDataRepository,
private val database: MangaDatabase, private val database: MangaDatabase,
private val progressUpdateUseCase: ProgressUpdateUseCase, private val progressUpdateUseCase: ProgressUpdateUseCase,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>, private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
) {
suspend operator fun invoke(
oldManga: Manga,
newManga: Manga,
) { ) {
suspend operator fun invoke( val oldDetails =
oldManga: Manga, if (oldManga.chapters.isNullOrEmpty()) {
newManga: Manga, runCatchingCancellable {
) { mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
val oldDetails = }.getOrDefault(oldManga)
if (oldManga.chapters.isNullOrEmpty()) { } else {
runCatchingCancellable { oldManga
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga) }
}.getOrDefault(oldManga) val newDetails =
} else { if (newManga.chapters.isNullOrEmpty()) {
oldManga 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)
} }
val newDetails = }
if (newManga.chapters.isNullOrEmpty()) { // replace history
mangaRepositoryFactory.create(newManga.source).getDetails(newManga) val historyDao = database.getHistoryDao()
val oldHistory = historyDao.find(oldDetails.id)
val newHistory =
if (oldHistory != null) {
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
historyDao.delete(oldDetails.id)
historyDao.upsert(newHistory)
newHistory
} else { } else {
newManga null
} }
mangaDataRepository.storeManga(newDetails) // track
database.withTransaction { val tracksDao = database.getTracksDao()
// replace favorites val oldTrack = tracksDao.find(oldDetails.id)
val favoritesDao = database.getFavouritesDao() if (oldTrack != null) {
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id) val lastChapter = newDetails.chapters?.lastOrNull()
if (oldFavourites.isNotEmpty()) { val newTrack =
favoritesDao.delete(oldManga.id) TrackEntity(
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)
val newHistory =
if (oldHistory != null) {
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
historyDao.delete(oldDetails.id)
historyDao.upsert(newHistory)
newHistory
} else {
null
}
// track
val tracksDao = database.getTracksDao()
val oldTrack = tracksDao.find(oldDetails.id)
if (oldTrack != null) {
val lastChapter = newDetails.chapters?.lastOrNull()
val newTrack =
TrackEntity(
mangaId = newDetails.id,
lastChapterId = lastChapter?.id ?: 0L,
newChapters = 0,
lastCheckTime = System.currentTimeMillis(),
lastChapterDate = lastChapter?.uploadDate ?: 0L,
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
lastError = null,
)
tracksDao.delete(oldDetails.id)
tracksDao.upsert(newTrack)
}
// scrobbling
for (scrobbler in scrobblers) {
if (!scrobbler.isEnabled) {
continue
}
val prevInfo = scrobbler.getScrobblingInfoOrNull(oldDetails.id) ?: continue
scrobbler.unregisterScrobbling(oldDetails.id)
scrobbler.linkManga(newDetails.id, prevInfo.targetId)
scrobbler.updateScrobblingInfo(
mangaId = newDetails.id, mangaId = newDetails.id,
rating = prevInfo.rating, lastChapterId = lastChapter?.id ?: 0L,
status = newChapters = 0,
prevInfo.status ?: when { lastCheckTime = System.currentTimeMillis(),
newHistory == null -> ScrobblingStatus.PLANNED lastChapterDate = lastChapter?.uploadDate ?: 0L,
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
else -> ScrobblingStatus.READING lastError = null,
}, )
comment = prevInfo.comment, tracksDao.delete(oldDetails.id)
tracksDao.upsert(newTrack)
}
// scrobbling
for (scrobbler in scrobblers) {
if (!scrobbler.isEnabled) {
continue
}
val prevInfo = scrobbler.getScrobblingInfoOrNull(oldDetails.id) ?: continue
scrobbler.unregisterScrobbling(oldDetails.id)
scrobbler.linkManga(newDetails.id, prevInfo.targetId)
scrobbler.updateScrobblingInfo(
mangaId = newDetails.id,
rating = prevInfo.rating,
status =
prevInfo.status ?: when {
newHistory == null -> ScrobblingStatus.PLANNED
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
else -> ScrobblingStatus.READING
},
comment = prevInfo.comment,
)
if (newHistory != null) {
scrobbler.scrobble(
manga = newDetails,
chapterId = newHistory.chapterId,
) )
if (newHistory != null) {
scrobbler.scrobble(
manga = newDetails,
chapterId = newHistory.chapterId,
)
}
} }
} }
progressUpdateUseCase(newManga)
} }
progressUpdateUseCase(newManga)
}
private fun makeNewHistory( private fun makeNewHistory(
oldManga: Manga, oldManga: Manga,
newManga: Manga, newManga: Manga,
history: HistoryEntity, history: HistoryEntity,
): HistoryEntity { ): HistoryEntity {
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
val branch = newManga.getPreferredBranch(null) val branch = newManga.getPreferredBranch(null)
val chapters = checkNotNull(newManga.getChapters(branch)) val chapters = checkNotNull(newManga.getChapters(branch))
val currentChapter = val currentChapter =
if (history.percent in 0f..1f) { if (history.percent in 0f..1f) {
chapters[(chapters.lastIndex * history.percent).toInt()] 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 { } else {
newManga.getPreferredBranch(null) chapters.first()
} }
val newChapterId =
checkNotNull(newChapters[newBranch])
.let {
val oldChapter = oldChapters[index]
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
}.id
return HistoryEntity( return HistoryEntity(
mangaId = newManga.id, mangaId = newManga.id,
createdAt = history.createdAt, createdAt = history.createdAt,
updatedAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis(),
chapterId = newChapterId, chapterId = currentChapter.id,
page = history.page, page = history.page,
scroll = history.scroll, scroll = history.scroll,
percent = PROGRESS_NONE, percent = history.percent,
deletedAt = 0, deletedAt = 0,
chaptersCount = checkNotNull(newChapters[newBranch]).size, chaptersCount = chapters.count { it.branch == currentChapter.branch },
) )
} }
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
private fun List<MangaChapter>.findByNumber( val oldChapters = checkNotNull(oldManga.getChapters(branch))
volume: Int, var index = oldChapters.indexOfFirst { it.id == history.chapterId }
number: Float, if (index < 0) {
): MangaChapter? = index =
if (number <= 0f) { if (history.percent in 0f..1f) {
null (oldChapters.lastIndex * history.percent).toInt()
} else {
0
}
}
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
val newBranch =
if (newChapters.containsKey(branch)) {
branch
} else { } else {
firstOrNull { it.volume == volume && it.number == number } 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? =
if (number <= 0f) {
null
} else {
firstOrNull { it.volume == volume && it.number == number }
}
}

@ -10,6 +10,7 @@ import coil.request.ImageRequest
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
@ -61,9 +62,9 @@ fun alternativeAD(
} }
} }
} }
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads) binding.progressView.setProgress(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
binding.chipSource.also { chip -> binding.chipSource.also { chip ->
chip.text = item.manga.source.title chip.text = item.manga.source.getTitle(chip.context)
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(item.manga.source.faviconUri()) .data(item.manga.source.faviconUri())
.lifecycle(lifecycleOwner) .lifecycle(lifecycleOwner)

@ -13,6 +13,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
@ -95,9 +96,9 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
getString( getString(
R.string.migrate_confirmation, R.string.migrate_confirmation,
viewModel.manga.title, viewModel.manga.title,
viewModel.manga.source.title, viewModel.manga.source.getTitle(this),
target.title, target.title,
target.source.title, target.source.getTitle(this),
), ),
) )
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)

@ -15,11 +15,13 @@ import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingFooter
@ -34,7 +36,8 @@ class AlternativesViewModel @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val alternativesUseCase: AlternativesUseCase, private val alternativesUseCase: AlternativesUseCase,
private val migrateUseCase: MigrateUseCase, private val migrateUseCase: MigrateUseCase,
private val extraProvider: ListExtraProvider, private val historyRepository: HistoryRepository,
private val settings: AppSettings,
) : BaseViewModel() { ) : BaseViewModel() {
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
@ -53,7 +56,7 @@ class AlternativesViewModel @Inject constructor(
.map { .map {
MangaAlternativeModel( MangaAlternativeModel(
manga = it, manga = it,
progress = extraProvider.getProgress(it.id), progress = getProgress(it.id),
referenceChapters = refCount, referenceChapters = refCount,
) )
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item -> }.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
@ -86,13 +89,7 @@ class AlternativesViewModel @Inject constructor(
} }
} }
private suspend fun mapList(list: List<Manga>, refCount: Int): List<MangaAlternativeModel> { private suspend fun getProgress(mangaId: Long): ReadingProgress? {
return list.map { return historyRepository.getProgress(mangaId, settings.progressIndicatorMode)
MangaAlternativeModel(
manga = it,
progress = extraProvider.getProgress(it.id),
referenceChapters = refCount,
)
}
} }
} }

@ -1,12 +1,13 @@
package org.koitharu.kotatsu.alternatives.ui package org.koitharu.kotatsu.alternatives.ui
import org.koitharu.kotatsu.core.model.chaptersCount import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
data class MangaAlternativeModel( data class MangaAlternativeModel(
val manga: Manga, val manga: Manga,
val progress: Float, val progress: ReadingProgress?,
private val referenceChapters: Int, private val referenceChapters: Int,
) : ListModel { ) : ListModel {

@ -37,6 +37,6 @@ fun bookmarkLargeAD(
source(item.manga.source) source(item.manga.source)
enqueueWith(coil) enqueueWith(coil)
} }
binding.progressView.percent = item.percent binding.progressView.setProgress(item.percent, false)
} }
} }

@ -12,13 +12,14 @@ import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import okhttp3.internal.userAgent
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@ -42,10 +43,9 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
setDisplayHomeAsUpEnabled(true) setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
} }
val userAgent = intent?.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)?.let { source -> val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
val repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
repository?.headers?.get(CommonHeaders.USER_AGENT) repository?.headers?.get(CommonHeaders.USER_AGENT)
}
viewBinding.webView.configureForParser(userAgent) viewBinding.webView.configureForParser(userAgent)
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true) CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
viewBinding.webView.webViewClient = BrowserClient(this) viewBinding.webView.webViewClient = BrowserClient(this)
@ -147,7 +147,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
return Intent(context, BrowserActivity::class.java) return Intent(context, BrowserActivity::class.java)
.setData(Uri.parse(url)) .setData(Uri.parse(url))
.putExtra(EXTRA_TITLE, title) .putExtra(EXTRA_TITLE, title)
.putExtra(EXTRA_SOURCE, source) .putExtra(EXTRA_SOURCE, source?.name)
} }
} }
} }

@ -14,8 +14,9 @@ import coil.request.ErrorResult
import coil.request.ImageRequest import coil.request.ImageRequest
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
class CaptchaNotifier( class CaptchaNotifier(
@ -46,7 +47,7 @@ class CaptchaNotifier(
.setGroup(GROUP_CAPTCHA) .setGroup(GROUP_CAPTCHA)
.setAutoCancel(true) .setAutoCancel(true)
.setVisibility( .setVisibility(
if (exception.source?.contentType == ContentType.HENTAI) { if (exception.source?.isNsfw() == true) {
NotificationCompat.VISIBILITY_SECRET NotificationCompat.VISIBILITY_SECRET
} else { } else {
NotificationCompat.VISIBILITY_PUBLIC NotificationCompat.VISIBILITY_PUBLIC
@ -55,7 +56,7 @@ class CaptchaNotifier(
.setContentText( .setContentText(
context.getString( context.getString(
R.string.captcha_required_summary, R.string.captcha_required_summary,
exception.source?.title ?: context.getString(R.string.app_name), exception.source?.getTitle(context) ?: context.getString(R.string.app_name),
), ),
) )
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false)) .setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))

@ -55,7 +55,11 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
setDisplayHomeAsUpEnabled(true) setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
} }
val url = intent?.dataString.orEmpty() val url = intent?.dataString
if (url.isNullOrEmpty()) {
finishAfterTransition()
return
}
cfClient = CloudFlareClient(cookieJar, this, url) cfClient = CloudFlareClient(cookieJar, this, url)
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA)) viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
viewBinding.webView.webViewClient = cfClient viewBinding.webView.webViewClient = cfClient
@ -63,12 +67,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
onBackPressedDispatcher.addCallback(it) onBackPressedDispatcher.addCallback(it)
} }
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true) CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
if (savedInstanceState != null) { if (savedInstanceState == null) {
return
}
if (url.isEmpty()) {
finishAfterTransition()
} else {
onTitleChanged(getString(R.string.loading_), url) onTitleChanged(getString(R.string.loading_), url)
viewBinding.webView.loadUrl(url) viewBinding.webView.loadUrl(url)
} }

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core package org.koitharu.kotatsu.core
import android.app.Application import android.app.Application
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.provider.SearchRecentSuggestions import android.provider.SearchRecentSuggestions
import android.text.Html import android.text.Html
@ -110,6 +111,8 @@ interface AppModule {
.decoderDispatcher(Dispatchers.IO) .decoderDispatcher(Dispatchers.IO)
.transformationDispatcher(Dispatchers.Default) .transformationDispatcher(Dispatchers.Default)
.diskCache(diskCacheFactory) .diskCache(diskCacheFactory)
.respectCacheHeaders(false)
.networkObserverEnabled(false)
.logger(if (BuildConfig.DEBUG) DebugLogger() else null) .logger(if (BuildConfig.DEBUG) DebugLogger() else null)
.allowRgb565(context.isLowRamDevice()) .allowRgb565(context.isLowRamDevice())
.eventListener(CaptchaNotifier(context)) .eventListener(CaptchaNotifier(context))

@ -4,7 +4,7 @@ import android.content.Context
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
class Migration16To17(context: Context) : Migration(16, 17) { class Migration16To17(context: Context) : Migration(16, 17) {
@ -15,11 +15,8 @@ class Migration16To17(context: Context) : Migration(16, 17) {
db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)") db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty() val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty() val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
val sources = MangaSource.entries val sources = MangaParserSource.entries
for (source in sources) { for (source in sources) {
if (source == MangaSource.LOCAL) {
continue
}
val name = source.name val name = source.name
val isHidden = name in hiddenSources val isHidden = name in hiddenSources
var sortKey = order.indexOf(name) var sortKey = order.indexOf(name)

@ -11,7 +11,6 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.formatSimple import org.koitharu.kotatsu.parsers.util.formatSimple
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
@ -109,7 +108,7 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
} }
val Manga.isLocal: Boolean val Manga.isLocal: Boolean
get() = source == MangaSource.LOCAL get() = source == LocalMangaSource
val Manga.appUrl: Uri val Manga.appUrl: Uri
get() = Uri.parse("https://kotatsu.app/manga").buildUpon() get() = Uri.parse("https://kotatsu.app/manga").buildUpon()

@ -7,24 +7,47 @@ import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan import android.text.style.RelativeSizeSpan
import android.text.style.SuperscriptSpan import android.text.style.SuperscriptSpan
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans import androidx.core.text.inSpans
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.core.util.ext.getDisplayName import org.koitharu.kotatsu.core.util.ext.getDisplayName
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.core.util.ext.toLocale
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.splitTwoParts
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
fun MangaSource(name: String): MangaSource { data object LocalMangaSource : MangaSource {
MangaSource.entries.forEach { override val name = "LOCAL"
}
data object UnknownMangaSource : MangaSource {
override val name = "UNKNOWN"
}
fun MangaSource(name: String?): MangaSource {
when (name ?: return UnknownMangaSource) {
UnknownMangaSource.name -> return UnknownMangaSource
LocalMangaSource.name -> return LocalMangaSource
}
if (name.startsWith("content:")) {
val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownMangaSource
return ExternalMangaSource(packageName = parts.first, authority = parts.second)
}
MangaParserSource.entries.forEach {
if (it.name == name) return it if (it.name == name) return it
} }
return MangaSource.UNKNOWN return UnknownMangaSource
} }
fun MangaSource.isNsfw() = contentType == ContentType.HENTAI fun MangaSource.isNsfw(): Boolean = when (this) {
is MangaSourceInfo -> mangaSource.isNsfw()
is MangaParserSource -> contentType == ContentType.HENTAI
else -> false
}
@get:StringRes @get:StringRes
val ContentType.titleResId val ContentType.titleResId
@ -35,23 +58,28 @@ val ContentType.titleResId
ContentType.OTHER -> R.string.content_type_other ContentType.OTHER -> R.string.content_type_other
} }
fun MangaSource.getSummary(context: Context): String { fun MangaSource.getSummary(context: Context): String? = when (this) {
val type = context.getString(contentType.titleResId) is MangaSourceInfo -> mangaSource.getSummary(context)
val locale = locale.toLocale().getDisplayName(context) is MangaParserSource -> {
return context.getString(R.string.source_summary_pattern, type, locale) val type = context.getString(contentType.titleResId)
val locale = locale.toLocale().getDisplayName(context)
context.getString(R.string.source_summary_pattern, type, locale)
}
is ExternalMangaSource -> context.getString(R.string.external_source)
else -> null
} }
fun MangaSource.getTitle(context: Context): CharSequence = if (isNsfw()) { fun MangaSource.getTitle(context: Context): String = when (this) {
buildSpannedString { is MangaSourceInfo -> mangaSource.getTitle(context)
append(title) is MangaParserSource -> title
append(' ') LocalMangaSource -> context.getString(R.string.local_storage)
appendNsfwLabel(context) is ExternalMangaSource -> resolveName(context)
} else -> context.getString(R.string.unknown)
} else {
title
} }
private fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans( fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)), ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)),
RelativeSizeSpan(0.74f), RelativeSizeSpan(0.74f),
SuperscriptSpan(), SuperscriptSpan(),

@ -0,0 +1,9 @@
package org.koitharu.kotatsu.core.model
import org.koitharu.kotatsu.parsers.model.MangaSource
data class MangaSourceInfo(
val mangaSource: MangaSource,
val isEnabled: Boolean,
val isPinned: Boolean,
) : MangaSource by mangaSource

@ -0,0 +1,15 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import kotlinx.parcelize.Parceler
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaSource
class MangaSourceParceler : Parceler<MangaSource> {
override fun create(parcel: Parcel): MangaSource = MangaSource(parcel.readString())
override fun MangaSource.write(parcel: Parcel, flags: Int) {
parcel.writeString(name)
}
}

@ -4,9 +4,8 @@ import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parceler import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
@Parcelize @Parcelize
data class ParcelableChapter( data class ParcelableChapter(
@ -25,8 +24,8 @@ data class ParcelableChapter(
scanlator = parcel.readString(), scanlator = parcel.readString(),
uploadDate = parcel.readLong(), uploadDate = parcel.readLong(),
branch = parcel.readString(), branch = parcel.readString(),
source = parcel.readSerializableCompat() ?: MangaSource.UNKNOWN, source = MangaSource(parcel.readString()),
) ),
) )
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) { override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
@ -38,7 +37,7 @@ data class ParcelableChapter(
parcel.writeString(scanlator) parcel.writeString(scanlator)
parcel.writeLong(uploadDate) parcel.writeLong(uploadDate)
parcel.writeString(branch) parcel.writeString(branch)
parcel.writeSerializable(source) parcel.writeString(source.name)
} }
} }
} }

@ -5,6 +5,7 @@ import android.os.Parcelable
import androidx.core.os.ParcelCompat import androidx.core.os.ParcelCompat
import kotlinx.parcelize.Parceler import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@ -30,7 +31,7 @@ data class ParcelableManga(
parcel.writeParcelable(ParcelableMangaTags(tags), flags) parcel.writeParcelable(ParcelableMangaTags(tags), flags)
parcel.writeSerializable(state) parcel.writeSerializable(state)
parcel.writeString(author) parcel.writeString(author)
parcel.writeSerializable(source) parcel.writeString(source.name)
} }
override fun create(parcel: Parcel) = ParcelableManga( override fun create(parcel: Parcel) = ParcelableManga(
@ -49,8 +50,8 @@ data class ParcelableManga(
state = parcel.readSerializableCompat(), state = parcel.readSerializableCompat(),
author = parcel.readString(), author = parcel.readString(),
chapters = null, chapters = null,
source = requireNotNull(parcel.readSerializableCompat()), source = MangaSource(parcel.readString()),
) ),
) )
} }
} }

@ -5,7 +5,7 @@ import android.os.Parcelable
import kotlinx.parcelize.Parceler import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
object MangaPageParceler : Parceler<MangaPage> { object MangaPageParceler : Parceler<MangaPage> {
@ -13,14 +13,14 @@ object MangaPageParceler : Parceler<MangaPage> {
id = parcel.readLong(), id = parcel.readLong(),
url = requireNotNull(parcel.readString()), url = requireNotNull(parcel.readString()),
preview = parcel.readString(), preview = parcel.readString(),
source = requireNotNull(parcel.readSerializableCompat()), source = MangaSource(parcel.readString()),
) )
override fun MangaPage.write(parcel: Parcel, flags: Int) { override fun MangaPage.write(parcel: Parcel, flags: Int) {
parcel.writeLong(id) parcel.writeLong(id)
parcel.writeString(url) parcel.writeString(url)
parcel.writeString(preview) parcel.writeString(preview)
parcel.writeSerializable(source) parcel.writeString(source.name)
} }
} }

@ -5,20 +5,20 @@ import android.os.Parcelable
import kotlinx.parcelize.Parceler import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
object MangaTagParceler : Parceler<MangaTag> { object MangaTagParceler : Parceler<MangaTag> {
override fun create(parcel: Parcel) = MangaTag( override fun create(parcel: Parcel) = MangaTag(
title = requireNotNull(parcel.readString()), title = requireNotNull(parcel.readString()),
key = requireNotNull(parcel.readString()), key = requireNotNull(parcel.readString()),
source = requireNotNull(parcel.readSerializableCompat()), source = MangaSource(parcel.readString()),
) )
override fun MangaTag.write(parcel: Parcel, flags: Int) { override fun MangaTag.write(parcel: Parcel, flags: Int) {
parcel.writeString(title) parcel.writeString(title)
parcel.writeString(key) parcel.writeString(key)
parcel.writeSerializable(source) parcel.writeString(source.name)
} }
} }

@ -11,7 +11,7 @@ import okio.IOException
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mergeWith import org.koitharu.kotatsu.parsers.util.mergeWith
@ -30,7 +30,7 @@ class CommonHeadersInterceptor @Inject constructor(
val request = chain.request() val request = chain.request()
val source = request.tag(MangaSource::class.java) val source = request.tag(MangaSource::class.java)
val repository = if (source != null) { val repository = if (source != null) {
mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
} else { } else {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.w("Http", "Request without source tag: ${request.url}") Log.w("Http", "Request without source tag: ${request.url}")

@ -13,8 +13,9 @@ import okhttp3.internal.canParseAsIpAddress
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import okhttp3.internal.publicsuffix.PublicSuffixDatabase import okhttp3.internal.publicsuffix.PublicSuffixDatabase
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.EnumMap import java.util.EnumMap
import javax.inject.Inject import javax.inject.Inject
@ -26,8 +27,8 @@ class MirrorSwitchInterceptor @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
) : Interceptor { ) : Interceptor {
private val locks = EnumMap<MangaSource, Any>(MangaSource::class.java) private val locks = EnumMap<MangaParserSource, Any>(MangaParserSource::class.java)
private val blacklist = EnumMap<MangaSource, MutableSet<String>>(MangaSource::class.java) private val blacklist = EnumMap<MangaParserSource, MutableSet<String>>(MangaParserSource::class.java)
val isEnabled: Boolean val isEnabled: Boolean
get() = settings.isMirrorSwitchingAvailable get() = settings.isMirrorSwitchingAvailable
@ -53,7 +54,7 @@ class MirrorSwitchInterceptor @Inject constructor(
} }
} }
suspend fun trySwitchMirror(repository: RemoteMangaRepository): Boolean = runInterruptible(Dispatchers.Default) { suspend fun trySwitchMirror(repository: ParserMangaRepository): Boolean = runInterruptible(Dispatchers.Default) {
if (!isEnabled) { if (!isEnabled) {
return@runInterruptible false return@runInterruptible false
} }
@ -75,14 +76,14 @@ class MirrorSwitchInterceptor @Inject constructor(
} }
} }
fun rollback(repository: RemoteMangaRepository, oldMirror: String) = synchronized(obtainLock(repository.source)) { fun rollback(repository: ParserMangaRepository, oldMirror: String) = synchronized(obtainLock(repository.source)) {
blacklist[repository.source]?.remove(oldMirror) blacklist[repository.source]?.remove(oldMirror)
repository.domain = oldMirror repository.domain = oldMirror
} }
private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? { private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? {
val source = request.tag(MangaSource::class.java) ?: return null val source = request.tag(MangaSource::class.java) ?: return null
val repository = mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository ?: return null val repository = mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository ?: return null
val mirrors = repository.getAvailableMirrors() val mirrors = repository.getAvailableMirrors()
if (mirrors.isEmpty()) { if (mirrors.isEmpty()) {
return null return null
@ -93,7 +94,7 @@ class MirrorSwitchInterceptor @Inject constructor(
} }
private fun tryMirrors( private fun tryMirrors(
repository: RemoteMangaRepository, repository: ParserMangaRepository,
mirrors: List<String>, mirrors: List<String>,
chain: Interceptor.Chain, chain: Interceptor.Chain,
request: Request, request: Request,
@ -145,15 +146,15 @@ class MirrorSwitchInterceptor @Inject constructor(
return source().readByteArray().toResponseBody(contentType()) return source().readByteArray().toResponseBody(contentType())
} }
private fun obtainLock(source: MangaSource): Any = locks.getOrPut(source) { private fun obtainLock(source: MangaParserSource): Any = locks.getOrPut(source) {
Any() Any()
} }
private fun isBlacklisted(source: MangaSource, domain: String): Boolean { private fun isBlacklisted(source: MangaParserSource, domain: String): Boolean {
return blacklist[source]?.contains(domain) == true return blacklist[source]?.contains(domain) == true
} }
private fun addToBlacklist(source: MangaSource, domain: String) { private fun addToBlacklist(source: MangaParserSource, domain: String) {
blacklist.getOrPut(source) { blacklist.getOrPut(source) {
ArraySet(2) ArraySet(2)
}.add(domain) }.add(domain)

@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@ -173,9 +174,10 @@ class AppShortcutManager @Inject constructor(
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) }, onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) }, onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
) )
val title = source.getTitle(context)
ShortcutInfoCompat.Builder(context, source.name) ShortcutInfoCompat.Builder(context, source.name)
.setShortLabel(source.title) .setShortLabel(title)
.setLongLabel(source.title) .setLongLabel(title)
.setIcon(icon) .setIcon(icon)
.setLongLived(true) .setLongLived(true)
.setIntent(MangaListActivity.newIntent(context, source)) .setIntent(MangaListActivity.newIntent(context, source))

@ -0,0 +1,104 @@
package org.koitharu.kotatsu.core.parser
import android.util.Log
import androidx.collection.MutableLongSet
import coil.request.CachePolicy
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.SafeDeferred
import org.koitharu.kotatsu.core.util.MultiMutex
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
abstract class CachingMangaRepository(
private val cache: MemoryContentCache,
) : MangaRepository {
private val detailsMutex = MultiMutex<Long>()
private val relatedMangaMutex = MultiMutex<Long>()
private val pagesMutex = MultiMutex<Long>()
final override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
final override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = pagesMutex.withLock(chapter.id) {
cache.getPages(source, chapter.url)?.let { return it }
val pages = asyncSafe {
getPagesImpl(chapter).distinctById()
}
cache.putPages(source, chapter.url, pages)
pages
}.await()
final override suspend fun getRelated(seed: Manga): List<Manga> = relatedMangaMutex.withLock(seed.id) {
cache.getRelatedManga(source, seed.url)?.let { return it }
val related = asyncSafe {
getRelatedMangaImpl(seed).filterNot { it.id == seed.id }
}
cache.putRelatedManga(source, seed.url, related)
related
}.await()
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga = detailsMutex.withLock(manga.id) {
if (cachePolicy.readEnabled) {
cache.getDetails(source, manga.url)?.let { return it }
}
val details = asyncSafe {
getDetailsImpl(manga)
}
if (cachePolicy.writeEnabled) {
cache.putDetails(source, manga.url, details)
}
details
}.await()
suspend fun peekDetails(manga: Manga): Manga? {
return cache.getDetails(source, manga.url)
}
fun invalidateCache() {
cache.clear(source)
}
protected abstract suspend fun getDetailsImpl(manga: Manga): Manga
protected abstract suspend fun getRelatedMangaImpl(seed: Manga): List<Manga>
protected abstract suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage>
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
dispatcher = Dispatchers.Default
}
return SafeDeferred(
processLifecycleScope.async(dispatcher) {
runCatchingCancellable { block() }
},
)
}
private fun List<MangaPage>.distinctById(): List<MangaPage> {
if (isEmpty()) {
return emptyList()
}
val result = ArrayList<MangaPage>(size)
val set = MutableLongSet(size)
for (page in this) {
if (set.add(page.id)) {
result.add(page)
} else if (BuildConfig.DEBUG) {
Log.w(null, "Duplicate page: $page")
}
}
return result
}
}

@ -8,7 +8,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet import java.util.EnumSet
@ -16,7 +16,7 @@ import java.util.EnumSet
/** /**
* This parser is just for parser development, it should not be used in releases * This parser is just for parser development, it should not be used in releases
*/ */
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) { class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParserSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("localhost") get() = ConfigKey.Domain("localhost")

@ -1,38 +1,49 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet import java.util.EnumSet
import java.util.Locale
/** class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
* This parser is just for parser development, it should not be used in releases
*/
class EmptyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain override val sortOrders: Set<SortOrder>
get() = ConfigKey.Domain("localhost")
override val availableSortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java) get() = EnumSet.allOf(SortOrder::class.java)
override val states: Set<MangaState>
override suspend fun getDetails(manga: Manga): Manga = stub(manga) get() = emptySet()
override val contentRatings: Set<ContentRating>
get() = emptySet()
override var defaultSortOrder: SortOrder
get() = SortOrder.NEWEST
set(value) = Unit
override val isMultipleTagsSupported: Boolean
get() = false
override val isTagsExclusionSupported: Boolean
get() = false
override val isSearchSupported: Boolean
get() = false
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null) override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null) override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
override suspend fun getAvailableTags(): Set<MangaTag> = stub(null) override suspend fun getPageUrl(page: MangaPage): String = stub(null)
override suspend fun getTags(): Set<MangaTag> = stub(null)
override suspend fun getLocales(): Set<Locale> = stub(null)
override suspend fun getRelatedManga(seed: Manga): List<Manga> = stub(seed) override suspend fun getRelated(seed: Manga): List<Manga> = stub(seed)
private fun stub(manga: Manga?): Nothing { private fun stub(manga: Manga?): Nothing {
throw UnsupportedSourceException("This manga source is not supported", manga) throw UnsupportedSourceException("This manga source is not supported", manga)

@ -12,6 +12,7 @@ import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.core.util.ext.toFileOrNull
@ -101,7 +102,7 @@ class MangaDataRepository @Inject constructor(
suspend fun cleanupLocalManga() { suspend fun cleanupLocalManga() {
val dao = db.getMangaDao() val dao = db.getMangaDao()
val broken = dao.findAllBySource(MangaSource.LOCAL.name) val broken = dao.findAllBySource(LocalMangaSource.name)
.filter { x -> x.manga.url.toUri().toFileOrNull()?.exists() == false } .filter { x -> x.manga.url.toUri().toFileOrNull()?.exists() == false }
if (broken.isNotEmpty()) { if (broken.isNotEmpty()) {
dao.delete(broken.map { it.manga }) dao.delete(broken.map { it.manga })

@ -4,10 +4,11 @@ import android.net.Uri
import coil.request.CachePolicy import coil.request.CachePolicy
import dagger.Reusable import dagger.Reusable
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@ -36,7 +37,7 @@ class MangaLinkResolver @Inject constructor(
require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" } require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" }
val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" } val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" }
val source = MangaSource(sourceName) val source = MangaSource(sourceName)
require(source != MangaSource.UNKNOWN) { "Manga source $sourceName is not supported" } require(source != UnknownMangaSource) { "Manga source $sourceName is not supported" }
val repo = repositoryFactory.create(source) val repo = repositoryFactory.create(source)
return repo.findExact( return repo.findExact(
url = uri.getQueryParameter("url"), url = uri.getQueryParameter("url"),
@ -51,7 +52,7 @@ class MangaLinkResolver @Inject constructor(
val host = uri.host ?: return null val host = uri.host ?: return null
val repo = sourcesRepository.allMangaSources.asSequence() val repo = sourcesRepository.allMangaSources.asSequence()
.map { source -> .map { source ->
repositoryFactory.create(source) as RemoteMangaRepository repositoryFactory.create(source) as ParserMangaRepository
}.find { repo -> }.find { repo ->
host in repo.domains host in repo.domains
} ?: return null } ?: return null
@ -85,7 +86,7 @@ class MangaLinkResolver @Inject constructor(
} }
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga { private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
return if (this is RemoteMangaRepository) { return if (this is ParserMangaRepository) {
getDetails(manga, CachePolicy.READ_ONLY) getDetails(manga, CachePolicy.READ_ONLY)
} else { } else {
getDetails(manga) getDetails(manga)
@ -108,7 +109,7 @@ class MangaLinkResolver @Inject constructor(
url = url, url = url,
publicUrl = "", publicUrl = "",
rating = 0.0f, rating = 0.0f,
isNsfw = source.contentType == ContentType.HENTAI, isNsfw = source.isNsfw(),
coverUrl = "", coverUrl = "",
tags = emptySet(), tags = emptySet(),
state = null, state = null,

@ -2,12 +2,11 @@ package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaParser { fun MangaParser(source: MangaParserSource, loaderContext: MangaLoaderContext): MangaParser {
return when (source) { return when (source) {
MangaSource.UNKNOWN -> EmptyParser(loaderContext) MangaParserSource.DUMMY -> DummyParser(loaderContext)
MangaSource.DUMMY -> DummyParser(loaderContext)
else -> loaderContext.newParserInstance(source) else -> loaderContext.newParserInstance(source)
} }
} }

@ -1,8 +1,16 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import android.content.Context
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.collection.ArrayMap
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
@ -10,12 +18,12 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.EnumMap
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -53,32 +61,60 @@ interface MangaRepository {
suspend fun getRelated(seed: Manga): List<Manga> suspend fun getRelated(seed: Manga): List<Manga>
suspend fun find(manga: Manga): Manga? {
val list = getList(0, MangaListFilter.Search(manga.title))
return list.find { x -> x.id == manga.id }
}
@Singleton @Singleton
class Factory @Inject constructor( class Factory @Inject constructor(
@ApplicationContext private val context: Context,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val loaderContext: MangaLoaderContext, private val loaderContext: MangaLoaderContext,
private val contentCache: MemoryContentCache, private val contentCache: MemoryContentCache,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor, private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
) { ) {
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java) private val cache = ArrayMap<MangaSource, WeakReference<MangaRepository>>()
@AnyThread @AnyThread
fun create(source: MangaSource): MangaRepository { fun create(source: MangaSource): MangaRepository {
if (source == MangaSource.LOCAL) { when (source) {
return localMangaRepository is MangaSourceInfo -> return create(source.mangaSource)
LocalMangaSource -> return localMangaRepository
UnknownMangaSource -> return EmptyMangaRepository(source)
} }
cache[source]?.get()?.let { return it } cache[source]?.get()?.let { return it }
return synchronized(cache) { return synchronized(cache) {
cache[source]?.get()?.let { return it } cache[source]?.get()?.let { return it }
val repository = RemoteMangaRepository( val repository = createRepository(source)
parser = MangaParser(source, loaderContext), if (repository != null) {
cache[source] = WeakReference(repository)
repository
} else {
EmptyMangaRepository(source)
}
}
}
private fun createRepository(source: MangaSource): MangaRepository? = when (source) {
is MangaParserSource -> ParserMangaRepository(
parser = MangaParser(source, loaderContext),
cache = contentCache,
mirrorSwitchInterceptor = mirrorSwitchInterceptor,
)
is ExternalMangaSource -> if (source.isAvailable(context)) {
ExternalMangaRepository(
contentResolver = context.contentResolver,
source = source,
cache = contentCache, cache = contentCache,
mirrorSwitchInterceptor = mirrorSwitchInterceptor,
) )
cache[source] = WeakReference(repository) } else {
repository EmptyMangaRepository(source)
} }
else -> null
} }
} }
} }

@ -1,24 +1,11 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import android.util.Log
import androidx.collection.MutableLongSet
import coil.request.CachePolicy
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
import okhttp3.Headers import okhttp3.Headers
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.SafeDeferred
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.MultiMutex
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
@ -28,7 +15,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@ -36,17 +23,13 @@ import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Locale import java.util.Locale
class RemoteMangaRepository( class ParserMangaRepository(
private val parser: MangaParser, private val parser: MangaParser,
private val cache: MemoryContentCache,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor, private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
) : MangaRepository, Interceptor { cache: MemoryContentCache,
) : CachingMangaRepository(cache), Interceptor {
private val detailsMutex = MultiMutex<Long>() override val source: MangaParserSource
private val relatedMangaMutex = MultiMutex<Long>()
private val pagesMutex = MultiMutex<Long>()
override val source: MangaSource
get() = parser.source get() = parser.source
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
@ -99,18 +82,11 @@ class RemoteMangaRepository(
} }
} }
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED) override suspend fun getPagesImpl(
chapter: MangaChapter
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = pagesMutex.withLock(chapter.id) { ): List<MangaPage> = mirrorSwitchInterceptor.withMirrorSwitching {
cache.getPages(source, chapter.url)?.let { return it } parser.getPages(chapter)
val pages = asyncSafe { }
mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPages(chapter).distinctById()
}
}
cache.putPages(source, chapter.url, pages)
pages
}.await()
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching { override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPageUrl(page) parser.getPageUrl(page)
@ -128,37 +104,10 @@ class RemoteMangaRepository(
parser.getFavicons() parser.getFavicons()
} }
override suspend fun getRelated(seed: Manga): List<Manga> = relatedMangaMutex.withLock(seed.id) { override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = parser.getRelatedManga(seed)
cache.getRelatedManga(source, seed.url)?.let { return it }
val related = asyncSafe {
parser.getRelatedManga(seed).filterNot { it.id == seed.id }
}
cache.putRelatedManga(source, seed.url, related)
related
}.await()
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga = detailsMutex.withLock(manga.id) {
if (cachePolicy.readEnabled) {
cache.getDetails(source, manga.url)?.let { return it }
}
val details = asyncSafe {
mirrorSwitchInterceptor.withMirrorSwitching {
parser.getDetails(manga)
}
}
if (cachePolicy.writeEnabled) {
cache.putDetails(source, manga.url, details)
}
details
}.await()
suspend fun peekDetails(manga: Manga): Manga? { override suspend fun getDetailsImpl(manga: Manga): Manga = mirrorSwitchInterceptor.withMirrorSwitching {
return cache.getDetails(source, manga.url) parser.getDetails(manga)
}
suspend fun find(manga: Manga): Manga? {
val list = getList(0, MangaListFilter.Search(manga.title))
return list.find { x -> x.id == manga.id }
} }
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
@ -175,40 +124,8 @@ class RemoteMangaRepository(
return getConfig().isSlowdownEnabled return getConfig().isSlowdownEnabled
} }
fun invalidateCache() {
cache.clear(source)
}
fun getConfig() = parser.config as SourceSettings fun getConfig() = parser.config as SourceSettings
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
dispatcher = Dispatchers.Default
}
return SafeDeferred(
processLifecycleScope.async(dispatcher) {
runCatchingCancellable { block() }
},
)
}
private fun List<MangaPage>.distinctById(): List<MangaPage> {
if (isEmpty()) {
return emptyList()
}
val result = ArrayList<MangaPage>(size)
val set = MutableLongSet(size)
for (page in this) {
if (set.add(page.id)) {
result.add(page)
} else if (BuildConfig.DEBUG) {
Log.w(null, "Duplicate page: $page")
}
}
return result
}
private suspend fun <R> MirrorSwitchInterceptor.withMirrorSwitching(block: suspend () -> R): R { private suspend fun <R> MirrorSwitchInterceptor.withMirrorSwitching(block: suspend () -> R): R {
if (!isEnabled) { if (!isEnabled) {
return block() return block()
@ -220,14 +137,14 @@ class RemoteMangaRepository(
if (result.isValidResult()) { if (result.isValidResult()) {
return result.getOrThrow() return result.getOrThrow()
} }
return if (trySwitchMirror(this@RemoteMangaRepository)) { return if (trySwitchMirror(this@ParserMangaRepository)) {
val newResult = runCatchingCancellable { val newResult = runCatchingCancellable {
block() block()
} }
if (newResult.isValidResult()) { if (newResult.isValidResult()) {
return newResult.getOrThrow() return newResult.getOrThrow()
} else { } else {
rollback(this@RemoteMangaRepository, initialMirror) rollback(this@ParserMangaRepository, initialMirror)
return result.getOrThrow() return result.getOrThrow()
} }
} else { } else {

@ -0,0 +1,264 @@
package org.koitharu.kotatsu.core.parser.external
import android.content.ContentResolver
import android.database.Cursor
import androidx.collection.ArraySet
import androidx.core.database.getStringOrNull
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.splitTwoParts
import java.util.EnumSet
import java.util.Locale
class ExternalMangaRepository(
private val contentResolver: ContentResolver,
override val source: ExternalMangaSource,
cache: MemoryContentCache,
) : CachingMangaRepository(cache) {
private val capabilities by lazy { queryCapabilities() }
override val sortOrders: Set<SortOrder>
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL)
override val states: Set<MangaState>
get() = capabilities?.availableStates.orEmpty()
override val contentRatings: Set<ContentRating>
get() = capabilities?.availableContentRating.orEmpty()
override var defaultSortOrder: SortOrder
get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL
set(value) = Unit
override val isMultipleTagsSupported: Boolean
get() = capabilities?.isMultipleTagsSupported ?: true
override val isTagsExclusionSupported: Boolean
get() = capabilities?.isTagsExclusionSupported ?: false
override val isSearchSupported: Boolean
get() = capabilities?.isSearchSupported ?: true
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> =
runInterruptible(Dispatchers.Default) {
val uri = "content://${source.authority}/manga".toUri().buildUpon()
uri.appendQueryParameter("offset", offset.toString())
when (filter) {
is MangaListFilter.Advanced -> {
filter.tags.forEach { uri.appendQueryParameter("tag_include", it.key) }
filter.tagsExclude.forEach { uri.appendQueryParameter("tag_exclude", it.key) }
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
}
is MangaListFilter.Search -> {
uri.appendQueryParameter("query", filter.query)
}
null -> Unit
}
contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name)?.use { cursor ->
val result = ArrayList<Manga>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += cursor.getManga()
} while (cursor.moveToNext())
}
result
}.orEmpty()
}
override suspend fun getDetailsImpl(manga: Manga): Manga = coroutineScope {
val chapters = async { queryChapters(manga.url) }
val details = queryDetails(manga.url)
Manga(
id = manga.id,
title = details.title.ifBlank { manga.title },
altTitle = details.altTitle.ifNullOrEmpty { manga.altTitle },
url = details.url.ifEmpty { manga.url },
publicUrl = details.publicUrl.ifEmpty { manga.publicUrl },
rating = maxOf(details.rating, manga.rating),
isNsfw = details.isNsfw,
coverUrl = details.coverUrl.ifEmpty { manga.coverUrl },
tags = details.tags + manga.tags,
state = details.state ?: manga.state,
author = details.author.ifNullOrEmpty { manga.author },
largeCoverUrl = details.largeCoverUrl.ifNullOrEmpty { manga.largeCoverUrl },
description = details.description.ifNullOrEmpty { manga.description },
chapters = chapters.await(),
source = source,
)
}
override suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.Default) {
val uri = "content://${source.authority}/chapters".toUri()
.buildUpon()
.appendPath(chapter.url)
.build()
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val result = ArrayList<MangaPage>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaPage(
id = cursor.getLong(0),
url = cursor.getString(1),
preview = cursor.getStringOrNull(2),
source = source,
)
} while (cursor.moveToNext())
}
result
}.orEmpty()
}
override suspend fun getPageUrl(page: MangaPage): String = page.url
override suspend fun getTags(): Set<MangaTag> = runInterruptible(Dispatchers.Default) {
val uri = "content://${source.authority}/tags".toUri()
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val result = ArraySet<MangaTag>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaTag(
key = cursor.getString(0),
title = cursor.getString(1),
source = source,
)
} while (cursor.moveToNext())
}
result
}.orEmpty()
}
override suspend fun getLocales(): Set<Locale> = emptySet()
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO
private suspend fun queryDetails(url: String): Manga = runInterruptible(Dispatchers.Default) {
val uri = "content://${source.authority}/manga".toUri()
.buildUpon()
.appendPath(url)
.build()
checkNotNull(
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
cursor.moveToFirst()
cursor.getManga()
},
)
}
private suspend fun queryChapters(url: String): List<MangaChapter>? = runInterruptible(Dispatchers.Default) {
val uri = "content://${source.authority}/manga/chapters".toUri()
.buildUpon()
.appendPath(url)
.build()
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val result = ArrayList<MangaChapter>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaChapter(
id = cursor.getLong(0),
name = cursor.getString(1),
number = cursor.getFloat(2),
volume = cursor.getInt(3),
url = cursor.getString(4),
scanlator = cursor.getStringOrNull(5),
uploadDate = cursor.getLong(6),
branch = cursor.getStringOrNull(7),
source = source,
)
} while (cursor.moveToNext())
}
result
}
}
private fun Cursor.getManga() = Manga(
id = getLong(0),
title = getString(1),
altTitle = getStringOrNull(2),
url = getString(3),
publicUrl = getString(4),
rating = getFloat(5),
isNsfw = getInt(6) > 1,
coverUrl = getString(7),
tags = getStringOrNull(8)?.split(':')?.mapNotNullToSet {
val parts = it.splitTwoParts('=') ?: return@mapNotNullToSet null
MangaTag(key = parts.first, title = parts.second, source = source)
}.orEmpty(),
state = getStringOrNull(9)?.let { MangaState.entries.find(it) },
author = optString(10),
largeCoverUrl = optString(11),
description = optString(12),
chapters = emptyList(),
source = source,
)
private fun Cursor.optString(columnIndex: Int): String? {
return if (isNull(columnIndex)) {
null
} else {
getString(columnIndex)
}
}
private fun queryCapabilities(): MangaSourceCapabilities? {
val uri = "content://${source.authority}/capabilities".toUri()
return contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
MangaSourceCapabilities(
availableSortOrders = cursor.getStringOrNull(0)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) {
SortOrder.entries.find(it)
}.orEmpty(),
availableStates = cursor.getStringOrNull(1)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(MangaState::class.java)) {
MangaState.entries.find(it)
}.orEmpty(),
availableContentRating = cursor.getStringOrNull(2)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(ContentRating::class.java)) {
ContentRating.entries.find(it)
}.orEmpty(),
isMultipleTagsSupported = cursor.getInt(3) > 1,
isTagsExclusionSupported = cursor.getInt(4) > 1,
isSearchSupported = cursor.getInt(5) > 1,
contentType = ContentType.entries.find(cursor.getString(6)) ?: ContentType.OTHER,
defaultSortOrder = cursor.getStringOrNull(7)?.let {
SortOrder.entries.find(it)
} ?: SortOrder.ALPHABETICAL,
sourceLocale = cursor.getStringOrNull(8)?.let { Locale(it) } ?: Locale.ROOT,
)
} else {
null
}
}
}
private class MangaSourceCapabilities(
val availableSortOrders: Set<SortOrder>,
val availableStates: Set<MangaState>,
val availableContentRating: Set<ContentRating>,
val isMultipleTagsSupported: Boolean,
val isTagsExclusionSupported: Boolean,
val isSearchSupported: Boolean,
val contentType: ContentType,
val defaultSortOrder: SortOrder,
val sourceLocale: Locale,
)
}

@ -0,0 +1,30 @@
package org.koitharu.kotatsu.core.parser.external
import android.content.Context
import org.koitharu.kotatsu.parsers.model.MangaSource
data class ExternalMangaSource(
val packageName: String,
val authority: String,
) : MangaSource {
override val name: String
get() = "content:$packageName/$authority"
private var cachedName: String? = null
fun isAvailable(context: Context): Boolean {
return context.packageManager.resolveContentProvider(authority, 0)?.isEnabled == true
}
fun resolveName(context: Context): String {
cachedName?.let {
return it
}
val pm = context.packageManager
val info = pm.resolveContentProvider(authority, 0)
return info?.loadLabel(pm)?.toString()?.also {
cachedName = it
} ?: authority
}
}

@ -1,12 +1,19 @@
package org.koitharu.kotatsu.core.parser.favicon package org.koitharu.kotatsu.core.parser.favicon
import android.content.Context import android.content.Context
import android.graphics.Color
import android.graphics.drawable.AdaptiveIconDrawable
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.net.Uri import android.net.Uri
import android.os.Build
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import coil.ImageLoader import coil.ImageLoader
import coil.decode.DataSource import coil.decode.DataSource
import coil.decode.ImageSource import coil.decode.ImageSource
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.fetch.DrawableResult
import coil.fetch.FetchResult import coil.fetch.FetchResult
import coil.fetch.Fetcher import coil.fetch.Fetcher
import coil.fetch.SourceResult import coil.fetch.SourceResult
@ -14,7 +21,9 @@ import coil.network.HttpException
import coil.request.Options import coil.request.Options
import coil.size.Size import coil.size.Size
import coil.size.pxOrElse import coil.size.pxOrElse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@ -24,8 +33,10 @@ import okio.Closeable
import okio.buffer import okio.buffer
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.util.ext.requireBody import org.koitharu.kotatsu.core.util.ext.requireBody
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
@ -46,14 +57,27 @@ class FaviconFetcher(
) : Fetcher { ) : Fetcher {
private val diskCacheKey private val diskCacheKey
get() = options.diskCacheKey ?: "${mangaSource.name}[${mangaSource.ordinal}]x${options.size.toCacheKey()}" get() = options.diskCacheKey ?: "${mangaSource.name}x${options.size.toCacheKey()}"
private val fileSystem private val fileSystem
get() = checkNotNull(diskCache.value).fileSystem get() = checkNotNull(diskCache.value).fileSystem
override suspend fun fetch(): FetchResult { override suspend fun fetch(): FetchResult {
getCached(options)?.let { return it } getCached(options)?.let { return it }
val repo = mangaRepositoryFactory.create(mangaSource) as RemoteMangaRepository return when (val repo = mangaRepositoryFactory.create(mangaSource)) {
is ParserMangaRepository -> fetchParserFavicon(repo)
is ExternalMangaRepository -> fetchPluginIcon(repo)
is EmptyMangaRepository -> DrawableResult(
drawable = ColorDrawable(Color.WHITE),
isSampled = false,
dataSource = DataSource.MEMORY,
)
else -> throw IllegalArgumentException("")
}
}
private suspend fun fetchParserFavicon(repo: ParserMangaRepository): FetchResult {
val sizePx = maxOf( val sizePx = maxOf(
options.size.width.pxOrElse { FALLBACK_SIZE }, options.size.width.pxOrElse { FALLBACK_SIZE },
options.size.height.pxOrElse { FALLBACK_SIZE }, options.size.height.pxOrElse { FALLBACK_SIZE },
@ -100,6 +124,20 @@ class FaviconFetcher(
return response return response
} }
private suspend fun fetchPluginIcon(repository: ExternalMangaRepository): FetchResult {
val source = repository.source
val pm = options.context.packageManager
val icon = runInterruptible(Dispatchers.IO) {
val provider = pm.resolveContentProvider(source.authority, 0)
provider?.loadIcon(pm) ?: pm.getApplicationIcon(source.packageName)
}
return DrawableResult(
drawable = icon.nonAdaptive(),
isSampled = false,
dataSource = DataSource.DISK,
)
}
private fun getCached(options: Options): SourceResult? { private fun getCached(options: Options): SourceResult? {
if (!options.diskCachePolicy.readEnabled) { if (!options.diskCachePolicy.readEnabled) {
return null return null
@ -165,6 +203,13 @@ class FaviconFetcher(
} }
} }
private fun Drawable.nonAdaptive() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this is AdaptiveIconDrawable) {
LayerDrawable(arrayOf(background, foreground))
} else {
this
}
class Factory( class Factory(
context: Context, context: Context,
okHttpClientLazy: Lazy<OkHttpClient>, okHttpClientLazy: Lazy<OkHttpClient>,

@ -192,8 +192,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_FEED_HEADER, true) get() = prefs.getBoolean(KEY_FEED_HEADER, true)
set(value) = prefs.edit { putBoolean(KEY_FEED_HEADER, value) } set(value) = prefs.edit { putBoolean(KEY_FEED_HEADER, value) }
val isReadingIndicatorsEnabled: Boolean val progressIndicatorMode: ProgressIndicatorMode
get() = prefs.getBoolean(KEY_READING_INDICATORS, true) get() = prefs.getEnumValue(KEY_PROGRESS_INDICATORS, ProgressIndicatorMode.PERCENT_READ)
val isHistoryExcludeNsfw: Boolean val isHistoryExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_HISTORY_EXCLUDE_NSFW, false) get() = prefs.getBoolean(KEY_HISTORY_EXCLUDE_NSFW, false)
@ -619,7 +619,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last" const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
const val KEY_HISTORY_GROUPING = "history_grouping" const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_UPDATED_GROUPING = "updated_grouping" const val KEY_UPDATED_GROUPING = "updated_grouping"
const val KEY_READING_INDICATORS = "reading_indicators" const val KEY_PROGRESS_INDICATORS = "progress_indicators"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters" const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
const val KEY_GRID_VIEW_CHAPTERS = "grid_view_chapters" const val KEY_GRID_VIEW_CHAPTERS = "grid_view_chapters"
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw" const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"

@ -0,0 +1,6 @@
package org.koitharu.kotatsu.core.prefs
enum class ProgressIndicatorMode {
NONE, PERCENT_READ, PERCENT_LEFT, CHAPTERS_READ, CHAPTERS_LEFT;
}

@ -0,0 +1,72 @@
package org.koitharu.kotatsu.core.ui.image
import android.animation.TimeAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.drawable.Animatable
import androidx.annotation.StyleRes
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import com.google.android.material.animation.ArgbEvaluatorCompat
import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import kotlin.math.abs
class AnimatedFaviconDrawable(
context: Context,
@StyleRes styleResId: Int,
name: String,
) : FaviconDrawable(context, styleResId, name), Animatable, TimeAnimator.TimeListener {
private val interpolator = FastOutSlowInInterpolator()
private val period = context.getAnimationDuration(R.integer.config_longAnimTime) * 2
private val timeAnimator = TimeAnimator()
private val colorHigh = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground)
private val colorLow = ArgbEvaluatorCompat.getInstance().evaluate(0.3f, colorHigh, colorBackground)
init {
timeAnimator.setTimeListener(this)
updateColor()
}
override fun draw(canvas: Canvas) {
if (!isRunning && period > 0) {
updateColor()
start()
}
super.draw(canvas)
}
override fun setAlpha(alpha: Int) = Unit
override fun getAlpha(): Int = 255
override fun onTimeUpdate(animation: TimeAnimator?, totalTime: Long, deltaTime: Long) {
callback?.also {
updateColor()
it.invalidateDrawable(this)
} ?: stop()
}
override fun start() {
timeAnimator.start()
}
override fun stop() {
timeAnimator.end()
}
override fun isRunning(): Boolean = timeAnimator.isStarted
private fun updateColor() {
if (period <= 0f) {
return
}
val ph = period / 2
val fraction = abs((System.currentTimeMillis() % period) - ph) / ph.toFloat()
colorForeground = ArgbEvaluatorCompat.getInstance()
.evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh)
}
}

@ -17,18 +17,18 @@ import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.KotatsuColors import org.koitharu.kotatsu.core.util.KotatsuColors
class FaviconDrawable( open class FaviconDrawable(
context: Context, context: Context,
@StyleRes styleResId: Int, @StyleRes styleResId: Int,
name: String, name: String,
) : Drawable() { ) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private var colorBackground = Color.WHITE protected var colorBackground = Color.WHITE
protected var colorForeground = Color.DKGRAY
private var colorStroke = Color.LTGRAY private var colorStroke = Color.LTGRAY
private val letter = name.take(1).uppercase() private val letter = name.take(1).uppercase()
private var cornerSize = 0f private var cornerSize = 0f
private var colorForeground = Color.DKGRAY
private val textBounds = Rect() private val textBounds = Rect()
private val tempRect = Rect() private val tempRect = Rect()
private val boundsF = RectF() private val boundsF = RectF()

@ -1,11 +1,11 @@
package org.koitharu.kotatsu.core.ui.list package org.koitharu.kotatsu.core.ui.list
import android.app.Notification.Action
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.collection.LongSet
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
@ -14,6 +14,8 @@ import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.SavedStateRegistryOwner
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.core.util.ext.toLongArray
import org.koitharu.kotatsu.core.util.ext.toSet
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
private const val KEY_SELECTION = "selection" private const val KEY_SELECTION = "selection"
@ -35,11 +37,9 @@ class ListSelectionController(
registryOwner.lifecycle.addObserver(StateEventObserver()) registryOwner.lifecycle.addObserver(StateEventObserver())
} }
fun snapshot(): Set<Long> { fun snapshot(): Set<Long> = peekCheckedIds().toSet()
return peekCheckedIds().toSet()
}
fun peekCheckedIds(): Set<Long> { fun peekCheckedIds(): LongSet {
return decoration.checkedItemsIds return decoration.checkedItemsIds
} }

@ -4,6 +4,8 @@ import android.graphics.Canvas
import android.graphics.Rect import android.graphics.Rect
import android.graphics.RectF import android.graphics.RectF
import android.view.View import android.view.View
import androidx.collection.LongSet
import androidx.collection.MutableLongSet
import androidx.core.view.children import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID import androidx.recyclerview.widget.RecyclerView.NO_ID
@ -12,7 +14,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
private val bounds = Rect() private val bounds = Rect()
private val boundsF = RectF() private val boundsF = RectF()
protected val selection = HashSet<Long>() protected val selection = MutableLongSet()
protected var hasBackground: Boolean = true protected var hasBackground: Boolean = true
protected var hasForeground: Boolean = false protected var hasForeground: Boolean = false
@ -21,7 +23,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
val checkedItemsCount: Int val checkedItemsCount: Int
get() = selection.size get() = selection.size
val checkedItemsIds: Set<Long> val checkedItemsIds: LongSet
get() = selection get() = selection
fun toggleItemChecked(id: Long) { fun toggleItemChecked(id: Long) {
@ -39,7 +41,9 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
} }
fun checkAll(ids: Collection<Long>) { fun checkAll(ids: Collection<Long>) {
selection.addAll(ids) for (id in ids) {
selection.add(id)
}
} }
fun clearSelection() { fun clearSelection() {

@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.util.ext
import androidx.collection.ArrayMap import androidx.collection.ArrayMap
import androidx.collection.ArraySet import androidx.collection.ArraySet
import androidx.collection.LongSet
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import java.util.Collections import java.util.Collections
import java.util.EnumSet import java.util.EnumSet
@ -77,3 +78,16 @@ inline fun <T, reified R> Collection<T>.mapToArray(transform: (T) -> R): Array<R
forEachIndexed { index, t -> result[index] = transform(t) } forEachIndexed { index, t -> result[index] = transform(t) }
return result as Array<R> return result as Array<R>
} }
fun LongSet.toLongArray(): LongArray {
val result = LongArray(size)
var i = 0
forEach { result[i++] = it }
return result
}
fun LongSet.toSet(): Set<Long> = toCollection(ArraySet<Long>(size))
fun <R : MutableCollection<Long>> LongSet.toCollection(out: R): R = out.also { result ->
forEach(result::add)
}

@ -55,11 +55,11 @@ class DetailsLoadUseCase @Inject constructor(
try { try {
val details = getDetails(manga) val details = getDetails(manga)
launch { updateTracker(details) } launch { updateTracker(details) }
send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false), false)) send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false)?.trim(), false))
send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true), true)) send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true)?.trim(), true))
} catch (e: IOException) { } catch (e: IOException) {
local?.await()?.manga?.also { localManga -> local?.await()?.manga?.also { localManga ->
send(MangaDetails(localManga, null, localManga.description?.parseAsHtml(withImages = false), true)) send(MangaDetails(localManga, null, localManga.description?.parseAsHtml(withImages = false)?.trim(), true))
} ?: close(e) } ?: close(e)
} }
} }

@ -5,7 +5,9 @@ import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.findById import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
@ -62,7 +64,7 @@ class MangaPrefetchService : CoroutineIntentService() {
private suspend fun prefetchLast() { private suspend fun prefetchLast() {
val last = historyRepository.getLastOrNull() ?: return val last = historyRepository.getLastOrNull() ?: return
if (last.source == MangaSource.LOCAL) return if (last.isLocal) return
val repo = mangaRepositoryFactory.create(last.source) val repo = mangaRepositoryFactory.create(last.source)
val details = runCatchingCancellable { repo.getDetails(last) }.getOrNull() ?: return val details = runCatchingCancellable { repo.getDetails(last) }.getOrNull() ?: return
val chapters = details.chapters val chapters = details.chapters
@ -110,7 +112,7 @@ class MangaPrefetchService : CoroutineIntentService() {
} }
private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean { private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean {
if (source == MangaSource.LOCAL || context.isPowerSaveMode()) { if (source == LocalMangaSource || context.isPowerSaveMode()) {
return false return false
} }
val entryPoint = EntryPointAccessors.fromApplication( val entryPoint = EntryPointAccessors.fromApplication(

@ -43,6 +43,9 @@ import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.iconResId import org.koitharu.kotatsu.core.model.iconResId
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.titleResId import org.koitharu.kotatsu.core.model.titleResId
@ -86,15 +89,14 @@ import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.ellipsize import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
@ -120,7 +122,7 @@ class DetailsActivity :
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
@Inject @Inject
lateinit var tagHighlighter: ListExtraProvider lateinit var listMapper: MangaListMapper
private val viewModel: DetailsViewModel by viewModels() private val viewModel: DetailsViewModel by viewModels()
private lateinit var menuProvider: DetailsMenuProvider private lateinit var menuProvider: DetailsMenuProvider
@ -389,7 +391,7 @@ class DetailsActivity :
} }
} }
private fun onRelatedMangaChanged(related: List<MangaItemModel>) { private fun onRelatedMangaChanged(related: List<MangaListModel>) {
if (related.isEmpty()) { if (related.isEmpty()) {
viewBinding.groupRelated.isVisible = false viewBinding.groupRelated.isVisible = false
return return
@ -463,10 +465,10 @@ class DetailsActivity :
imageViewState.isVisible = false imageViewState.isVisible = false
} }
if (manga.source == MangaSource.LOCAL || manga.source == MangaSource.UNKNOWN) { if (manga.source == LocalMangaSource || manga.source == UnknownMangaSource) {
infoLayout.chipSource.isVisible = false infoLayout.chipSource.isVisible = false
} else { } else {
infoLayout.chipSource.text = manga.source.title infoLayout.chipSource.text = manga.source.getTitle(this@DetailsActivity)
infoLayout.chipSource.isVisible = true infoLayout.chipSource.isVisible = true
} }
@ -611,15 +613,7 @@ class DetailsActivity :
private fun bindTags(manga: Manga) { private fun bindTags(manga: Manga) {
viewBinding.chipsTags.isVisible = manga.tags.isNotEmpty() viewBinding.chipsTags.isVisible = manga.tags.isNotEmpty()
viewBinding.chipsTags.setChips( viewBinding.chipsTags.setChips(listMapper.mapTags(manga.tags))
manga.tags.map { tag ->
ChipsView.ChipModel(
title = tag.title,
tint = tagHighlighter.getTagTint(tag),
data = tag,
)
},
)
} }
private fun loadCover(manga: Manga) { private fun loadCover(manga: Manga) {

@ -16,11 +16,12 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
@ -38,10 +39,10 @@ class DetailsMenuProvider(
override fun onPrepareMenu(menu: Menu) { override fun onPrepareMenu(menu: Menu) {
val manga = viewModel.manga.value val manga = viewModel.manga.value
menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != MangaSource.LOCAL menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != LocalMangaSource
menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL menu.findItem(R.id.action_delete).isVisible = manga?.source == LocalMangaSource
menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL menu.findItem(R.id.action_browser).isVisible = manga?.source != LocalMangaSource
menu.findItem(R.id.action_alternatives).isVisible = manga?.source != MangaSource.LOCAL menu.findItem(R.id.action_alternatives).isVisible = manga?.source != LocalMangaSource
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity) menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable 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_online).isVisible = viewModel.remoteManga.value != null
@ -53,7 +54,7 @@ class DetailsMenuProvider(
R.id.action_share -> { R.id.action_share -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
val shareHelper = ShareHelper(activity) val shareHelper = ShareHelper(activity)
if (it.source == MangaSource.LOCAL) { if (it.isLocal) {
shareHelper.shareCbz(listOf(it.url.toUri().toFile())) shareHelper.shareCbz(listOf(it.url.toUri().toFile()))
} else { } else {
shareHelper.shareMangaLink(it) shareHelper.shareMangaLink(it)

@ -50,9 +50,8 @@ import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.model.MangaBranch import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.model.MangaItemModel import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
@ -76,7 +75,7 @@ class DetailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
private val relatedMangaUseCase: RelatedMangaUseCase, private val relatedMangaUseCase: RelatedMangaUseCase,
private val extraProvider: ListExtraProvider, private val mangaListMapper: MangaListMapper,
private val detailsLoadUseCase: DetailsLoadUseCase, private val detailsLoadUseCase: DetailsLoadUseCase,
private val progressUpdateUseCase: ProgressUpdateUseCase, private val progressUpdateUseCase: ProgressUpdateUseCase,
private val readingTimeUseCase: ReadingTimeUseCase, private val readingTimeUseCase: ReadingTimeUseCase,
@ -171,9 +170,12 @@ class DetailsViewModel @Inject constructor(
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId) val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val relatedManga: StateFlow<List<MangaItemModel>> = manga.mapLatest { val relatedManga: StateFlow<List<MangaListModel>> = manga.mapLatest {
if (it != null && settings.isRelatedMangaEnabled) { if (it != null && settings.isRelatedMangaEnabled) {
relatedMangaUseCase.invoke(it)?.toUi(ListMode.GRID, extraProvider).orEmpty() mangaListMapper.toListModelList(
manga = relatedMangaUseCase(it).orEmpty(),
mode = ListMode.GRID,
)
} else { } else {
emptyList() emptyList()
} }

@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
@ -31,6 +32,8 @@ import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.findParentCallback import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.toCollection
import org.koitharu.kotatsu.core.util.ext.toSet
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.DetailsViewModel import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
@ -40,7 +43,6 @@ import org.koitharu.kotatsu.details.ui.withVolumeHeaders
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
@ -137,10 +139,10 @@ class ChaptersFragment :
val ids = selectionController?.peekCheckedIds() val ids = selectionController?.peekCheckedIds()
val manga = viewModel.manga.value val manga = viewModel.manga.value
when { when {
ids.isNullOrEmpty() || manga == null -> Unit ids == null || ids.isEmpty() || manga == null -> Unit
ids.size == manga.chapters?.size -> viewModel.deleteLocal() ids.size == manga.chapters?.size -> viewModel.deleteLocal()
else -> { else -> {
LocalChaptersRemoveService.start(requireContext(), manga, ids) LocalChaptersRemoveService.start(requireContext(), manga, ids.toSet())
Snackbar.make( Snackbar.make(
requireViewBinding().recyclerViewChapters, requireViewBinding().recyclerViewChapters,
R.string.chapters_will_removed_background, R.string.chapters_will_removed_background,
@ -154,7 +156,7 @@ class ChaptersFragment :
R.id.action_select_range -> { R.id.action_select_range -> {
val items = chaptersAdapter?.items ?: return false val items = chaptersAdapter?.items ?: return false
val ids = HashSet(controller.peekCheckedIds()) val ids = controller.peekCheckedIds().toCollection(HashSet())
val buffer = HashSet<Long>() val buffer = HashSet<Long>()
var isAdding = false var isAdding = false
for (x in items) { for (x in items) {
@ -188,8 +190,12 @@ class ChaptersFragment :
} }
R.id.action_mark_current -> { R.id.action_mark_current -> {
val id = controller.peekCheckedIds().singleOrNull() ?: return false val ids = controller.peekCheckedIds()
viewModel.markChapterAsCurrent(id) if (ids.size == 1) {
viewModel.markChapterAsCurrent(ids.first())
} else {
return false
}
mode.finish() mode.finish()
true true
} }
@ -218,7 +224,7 @@ class ChaptersFragment :
var canSave = true var canSave = true
var canDelete = true var canDelete = true
items.forEach { (_, x) -> items.forEach { (_, x) ->
val isLocal = x.isDownloaded || x.chapter.source == MangaSource.LOCAL val isLocal = x.isDownloaded || x.chapter.source == LocalMangaSource
if (isLocal) canSave = false else canDelete = false if (isLocal) canSave = false else canDelete = false
} }
menu.findItem(R.id.action_save).isVisible = canSave menu.findItem(R.id.action_save).isVisible = canSave

@ -20,12 +20,11 @@ import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject import javax.inject.Inject
@ -34,7 +33,7 @@ class RelatedListViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory, mangaRepositoryFactory: MangaRepository.Factory,
settings: AppSettings, settings: AppSettings,
private val extraProvider: ListExtraProvider, private val mangaListMapper: MangaListMapper,
downloadScheduler: DownloadWorker.Scheduler, downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler) { ) : MangaListViewModel(settings, downloadScheduler) {
@ -46,14 +45,14 @@ class RelatedListViewModel @Inject constructor(
override val content = combine( override val content = combine(
mangaList, mangaList,
listMode, observeListModeWithTriggers(),
listError, listError,
) { list, mode, error -> ) { list, mode, error ->
when { when {
list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true)) list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true))
list == null -> listOf(LoadingState) list == null -> listOf(LoadingState)
list.isEmpty() -> listOf(createEmptyState()) list.isEmpty() -> listOf(createEmptyState())
else -> list.toUi(mode, extraProvider) else -> mangaListMapper.toListModelList(list, mode)
} }
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.download.ui.list package org.koitharu.kotatsu.download.ui.list
import androidx.collection.ArrayMap import androidx.collection.ArrayMap
import androidx.collection.LongSet
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import androidx.collection.getOrElse import androidx.collection.getOrElse
import androidx.collection.set import androidx.collection.set
@ -24,7 +25,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.formatNumber import org.koitharu.kotatsu.core.model.formatNumber
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
@ -182,7 +183,7 @@ class DownloadsViewModel @Inject constructor(
} }
} }
fun snapshot(ids: Set<Long>): Collection<DownloadItemModel> { fun snapshot(ids: LongSet): Collection<DownloadItemModel> {
return works.value?.filterTo(ArrayList(ids.size)) { x -> x.id.mostSignificantBits in ids }.orEmpty() return works.value?.filterTo(ArrayList(ids.size)) { x -> x.id.mostSignificantBits in ids }.orEmpty()
} }
@ -325,6 +326,6 @@ class DownloadsViewModel @Inject constructor(
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
private suspend fun tryLoad(manga: Manga) = runCatchingCancellable { private suspend fun tryLoad(manga: Manga) = runCatchingCancellable {
(mangaRepositoryFactory.create(manga.source) as RemoteMangaRepository).getDetails(manga) (mangaRepositoryFactory.create(manga.source) as ParserMangaRepository).getDetails(manga)
}.getOrNull() }.getOrNull()
} }

@ -21,13 +21,13 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
@ -231,7 +231,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
if (manga != null) { if (manga != null) {
DetailsActivity.newIntent(context, manga) DetailsActivity.newIntent(context, manga)
} else { } else {
MangaListActivity.newIntent(context, MangaSource.LOCAL) MangaListActivity.newIntent(context, LocalMangaSource)
}, },
PendingIntent.FLAG_CANCEL_CURRENT, PendingIntent.FLAG_CANCEL_CURRENT,
false, false,

@ -3,7 +3,7 @@ package org.koitharu.kotatsu.download.ui.worker
import androidx.collection.MutableObjectLongMap import androidx.collection.MutableObjectLongMap
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
class DownloadSlowdownDispatcher( class DownloadSlowdownDispatcher(
@ -13,7 +13,7 @@ class DownloadSlowdownDispatcher(
private val timeMap = MutableObjectLongMap<MangaSource>() private val timeMap = MutableObjectLongMap<MangaSource>()
suspend fun delay(source: MangaSource) { suspend fun delay(source: MangaSource) {
val repo = mangaRepositoryFactory.create(source) as? RemoteMangaRepository ?: return val repo = mangaRepositoryFactory.create(source) as? ParserMangaRepository ?: return
if (!repo.isSlowdownEnabled()) { if (!repo.isSlowdownEnabled()) {
return return
} }

@ -43,6 +43,7 @@ import okio.use
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
@ -179,7 +180,7 @@ class DownloadWorker @AssistedInject constructor(
checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) } checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) }
var output: LocalMangaOutput? = null var output: LocalMangaOutput? = null
try { try {
if (manga.source == MangaSource.LOCAL) { if (manga.isLocal) {
manga = localMangaRepository.getRemoteManga(manga) manga = localMangaRepository.getRemoteManga(manga)
?: error("Cannot obtain remote manga instance") ?: error("Cannot obtain remote manga instance")
} }

@ -1,8 +1,16 @@
package org.koitharu.kotatsu.explore.data package org.koitharu.kotatsu.explore.data
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
import androidx.room.withTransaction import androidx.room.withTransaction
import dagger.Reusable import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
@ -12,20 +20,27 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import java.util.Collections import java.util.Collections
import java.util.EnumSet import java.util.EnumSet
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@Reusable @Singleton
class MangaSourcesRepository @Inject constructor( class MangaSourcesRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val db: MangaDatabase, private val db: MangaDatabase,
private val settings: AppSettings, private val settings: AppSettings,
) { ) {
@ -34,15 +49,13 @@ class MangaSourcesRepository @Inject constructor(
private val dao: MangaSourcesDao private val dao: MangaSourcesDao
get() = db.getSourcesDao() get() = db.getSourcesDao()
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply { private val remoteSources = EnumSet.allOf(MangaParserSource::class.java).apply {
remove(MangaSource.LOCAL)
remove(MangaSource.UNKNOWN)
if (!BuildConfig.DEBUG) { if (!BuildConfig.DEBUG) {
remove(MangaSource.DUMMY) remove(MangaParserSource.DUMMY)
} }
} }
val allMangaSources: Set<MangaSource> val allMangaSources: Set<MangaParserSource>
get() = Collections.unmodifiableSet(remoteSources) get() = Collections.unmodifiableSet(remoteSources)
suspend fun getEnabledSources(): List<MangaSource> { suspend fun getEnabledSources(): List<MangaSource> {
@ -54,7 +67,7 @@ class MangaSourcesRepository @Inject constructor(
suspend fun getPinnedSources(): Set<MangaSource> { suspend fun getPinnedSources(): Set<MangaSource> {
assimilateNewSources() assimilateNewSources()
val skipNsfw = settings.isNsfwContentDisabled val skipNsfw = settings.isNsfwContentDisabled
return dao.findAllPinned().mapNotNullTo(EnumSet.noneOf(MangaSource::class.java)) { return dao.findAllPinned().mapNotNullToSet {
it.source.toMangaSourceOrNull()?.takeUnless { x -> skipNsfw && x.isNsfw() } it.source.toMangaSourceOrNull()?.takeUnless { x -> skipNsfw && x.isNsfw() }
} }
} }
@ -75,7 +88,7 @@ class MangaSourcesRepository @Inject constructor(
return result return result
} }
suspend fun getAvailableSources( suspend fun queryParserSources(
isDisabledOnly: Boolean, isDisabledOnly: Boolean,
isNewOnly: Boolean, isNewOnly: Boolean,
excludeBroken: Boolean, excludeBroken: Boolean,
@ -83,7 +96,7 @@ class MangaSourcesRepository @Inject constructor(
query: String?, query: String?,
locale: String?, locale: String?,
sortOrder: SourcesSortOrder?, sortOrder: SourcesSortOrder?,
): List<MangaSource> { ): List<MangaParserSource> {
assimilateNewSources() assimilateNewSources()
val entities = dao.findAll().toMutableList() val entities = dao.findAll().toMutableList()
if (isDisabledOnly) { if (isDisabledOnly) {
@ -95,7 +108,9 @@ class MangaSourcesRepository @Inject constructor(
val sources = entities.toSources( val sources = entities.toSources(
skipNsfwSources = settings.isNsfwContentDisabled, skipNsfwSources = settings.isNsfwContentDisabled,
sortOrder = sortOrder, sortOrder = sortOrder,
) ).run {
mapNotNullTo(ArrayList(size)) { it.mangaSource as? MangaParserSource }
}
if (locale != null) { if (locale != null) {
sources.retainAll { it.locale == locale } sources.retainAll { it.locale == locale }
} }
@ -107,7 +122,7 @@ class MangaSourcesRepository @Inject constructor(
} }
if (!query.isNullOrEmpty()) { if (!query.isNullOrEmpty()) {
sources.retainAll { sources.retainAll {
it.title.contains(query, ignoreCase = true) || it.name.contains(query, ignoreCase = true) it.getTitle(context).contains(query, ignoreCase = true) || it.name.contains(query, ignoreCase = true)
} }
} }
return sources return sources
@ -140,14 +155,21 @@ class MangaSourcesRepository @Inject constructor(
}.distinctUntilChanged().onStart { assimilateNewSources() } }.distinctUntilChanged().onStart { assimilateNewSources() }
} }
fun observeEnabledSources(): Flow<List<MangaSource>> = combine( fun observeEnabledSources(): Flow<List<MangaSourceInfo>> = combine(
observeIsNsfwDisabled(), observeIsNsfwDisabled(),
observeSortOrder(), observeSortOrder(),
) { skipNsfw, order -> ) { skipNsfw, order ->
dao.observeEnabled(order).map { dao.observeEnabled(order).map {
it.toSources(skipNsfw, order) it.toSources(skipNsfw, order)
} }
}.flatMapLatest { it }.onStart { assimilateNewSources() } }.flatMapLatest { it }
.onStart { assimilateNewSources() }
.combine(observeExternalSources()) { enabled, external ->
val list = ArrayList<MangaSourceInfo>(enabled.size + external.size)
external.mapTo(list) { MangaSourceInfo(it, isEnabled = true, isPinned = true) }
list.addAll(enabled)
list
}
fun observeAll(): Flow<List<Pair<MangaSource, Boolean>>> = dao.observeAll().map { entities -> fun observeAll(): Flow<List<Pair<MangaSource, Boolean>>> = dao.observeAll().map { entities ->
val result = ArrayList<Pair<MangaSource, Boolean>>(entities.size) val result = ArrayList<Pair<MangaSource, Boolean>>(entities.size)
@ -264,6 +286,15 @@ class MangaSourcesRepository @Inject constructor(
} }
} }
private suspend fun getNewSources(): MutableSet<out MangaSource> {
val entities = dao.findAll()
val result = EnumSet.copyOf(remoteSources)
for (e in entities) {
result.remove(e.source.toMangaSourceOrNull() ?: continue)
}
return result
}
private suspend fun setSourcesPinnedImpl(sources: Collection<MangaSource>, isPinned: Boolean) { private suspend fun setSourcesPinnedImpl(sources: Collection<MangaSource>, isPinned: Boolean) {
if (sources.size == 1) { // fast path if (sources.size == 1) { // fast path
dao.setPinned(sources.first().name, isPinned) dao.setPinned(sources.first().name, isPinned)
@ -276,35 +307,63 @@ class MangaSourcesRepository @Inject constructor(
} }
} }
private suspend fun getNewSources(): MutableSet<MangaSource> { private fun observeExternalSources(): Flow<List<ExternalMangaSource>> {
val entities = dao.findAll() val intent = Intent("app.kotatsu.parser.PROVIDE_MANGA")
val result = EnumSet.copyOf(remoteSources) val pm = context.packageManager
for (e in entities) { return callbackFlow {
result.remove(e.source.toMangaSourceOrNull() ?: continue) val receiver = object : BroadcastReceiver() {
} override fun onReceive(context: Context?, intent: Intent?) {
return result trySendBlocking(intent)
}
}
ContextCompat.registerReceiver(
context,
receiver,
IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_VERIFIED)
addAction(Intent.ACTION_PACKAGE_REPLACED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
addDataScheme("package")
},
ContextCompat.RECEIVER_EXPORTED,
)
awaitClose { context.unregisterReceiver(receiver) }
}.onStart {
emit(null)
}.map {
pm.queryIntentContentProviders(intent, 0).map { resolveInfo ->
ExternalMangaSource(
packageName = resolveInfo.providerInfo.packageName,
authority = resolveInfo.providerInfo.authority,
)
}
}.distinctUntilChanged()
} }
private fun List<MangaSourceEntity>.toSources( private fun List<MangaSourceEntity>.toSources(
skipNsfwSources: Boolean, skipNsfwSources: Boolean,
sortOrder: SourcesSortOrder?, sortOrder: SourcesSortOrder?,
): MutableList<MangaSource> { ): MutableList<MangaSourceInfo> {
val result = ArrayList<MangaSource>(size) val result = ArrayList<MangaSourceInfo>(size)
val pinned = EnumSet.noneOf(MangaSource::class.java)
for (entity in this) { for (entity in this) {
val source = entity.source.toMangaSourceOrNull() ?: continue val source = entity.source.toMangaSourceOrNull() ?: continue
if (skipNsfwSources && source.isNsfw()) { if (skipNsfwSources && source.isNsfw()) {
continue continue
} }
if (source in remoteSources) { if (source in remoteSources) {
result.add(source) result.add(
if (entity.isPinned) { MangaSourceInfo(
pinned.add(source) mangaSource = source,
} isEnabled = entity.isEnabled,
isPinned = entity.isPinned,
),
)
} }
} }
if (sortOrder == SourcesSortOrder.ALPHABETIC) { if (sortOrder == SourcesSortOrder.ALPHABETIC) {
result.sortWith(compareBy<MangaSource> { it in pinned }.thenBy { it.title }) result.sortWith(compareBy<MangaSourceInfo> { !it.isPinned }.thenBy { it.getTitle(context) })
} }
return result return result
} }
@ -317,5 +376,5 @@ class MangaSourcesRepository @Inject constructor(
sourcesSortOrder sourcesSortOrder
} }
private fun String.toMangaSourceOrNull(): MangaSource? = MangaSource.entries.find { it.name == this } private fun String.toMangaSourceOrNull(): MangaParserSource? = MangaParserSource.entries.find { it.name == this }
} }

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.explore.domain package org.koitharu.kotatsu.explore.domain
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.almostEquals import org.koitharu.kotatsu.core.util.ext.almostEquals
@ -7,7 +8,6 @@ import org.koitharu.kotatsu.core.util.ext.asArrayList
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@ -45,7 +45,7 @@ class ExploreRepository @Inject constructor(
suspend fun findRandomManga(source: MangaSource, tagsLimit: Int): Manga { suspend fun findRandomManga(source: MangaSource, tagsLimit: Int): Manga {
val tagsBlacklist = TagsBlacklist(settings.suggestionsTagsBlacklist, 0.4f) val tagsBlacklist = TagsBlacklist(settings.suggestionsTagsBlacklist, 0.4f)
val skipNsfw = settings.isSuggestionsExcludeNsfw && source.contentType != ContentType.HENTAI val skipNsfw = settings.isSuggestionsExcludeNsfw && !source.isNsfw()
val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull { val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull {
if (it in tagsBlacklist) null else it.title if (it in tagsBlacklist) null else it.title
} }

@ -2,6 +2,8 @@ package org.koitharu.kotatsu.explore.ui
import android.content.DialogInterface import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
@ -20,6 +22,8 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
@ -40,8 +44,7 @@ import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
@ -123,7 +126,7 @@ class ExploreFragment :
override fun onClick(v: View) { override fun onClick(v: View) {
val intent = when (v.id) { val intent = when (v.id) {
R.id.button_local -> MangaListActivity.newIntent(v.context, MangaSource.LOCAL) R.id.button_local -> MangaListActivity.newIntent(v.context, LocalMangaSource)
R.id.button_bookmarks -> AllBookmarksActivity.newIntent(v.context) R.id.button_bookmarks -> AllBookmarksActivity.newIntent(v.context)
R.id.button_more -> SuggestionsActivity.newIntent(v.context) R.id.button_more -> SuggestionsActivity.newIntent(v.context)
R.id.button_downloads -> DownloadsActivity.newIntent(v.context) R.id.button_downloads -> DownloadsActivity.newIntent(v.context)
@ -165,16 +168,19 @@ class ExploreFragment :
} }
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
val isSingleSelection = controller.count == 1 val selectedSources = viewModel.sourcesSnapshot(controller.peekCheckedIds())
val isSingleSelection = selectedSources.size == 1
menu.findItem(R.id.action_settings).isVisible = isSingleSelection menu.findItem(R.id.action_settings).isVisible = isSingleSelection
menu.findItem(R.id.action_shortcut).isVisible = isSingleSelection menu.findItem(R.id.action_shortcut).isVisible = isSingleSelection
menu.findItem(R.id.action_pin).isVisible = selectedSources.all { !it.isPinned }
menu.findItem(R.id.action_unpin).isVisible = selectedSources.all { it.isPinned }
menu.findItem(R.id.action_disable)?.isVisible = selectedSources.all { it.mangaSource is MangaParserSource }
menu.findItem(R.id.action_delete)?.isVisible = selectedSources.all { it.mangaSource is ExternalMangaSource }
return super.onPrepareActionMode(controller, mode, menu) return super.onPrepareActionMode(controller, mode, menu)
} }
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
val selectedSources = controller.peekCheckedIds().mapNotNullToSet { id -> val selectedSources = viewModel.sourcesSnapshot(controller.peekCheckedIds())
MangaSource.entries.getOrNull(id.toInt())
}
if (selectedSources.isEmpty()) { if (selectedSources.isEmpty()) {
return false return false
} }
@ -190,6 +196,13 @@ class ExploreFragment :
mode.finish() mode.finish()
} }
R.id.action_delete -> {
selectedSources.forEach {
(it.mangaSource as? ExternalMangaSource)?.let { uninstallExternalSource(it) }
}
mode.finish()
}
R.id.action_shortcut -> { R.id.action_shortcut -> {
val source = selectedSources.singleOrNull() ?: return false val source = selectedSources.singleOrNull() ?: return false
viewModel.requestPinShortcut(source) viewModel.requestPinShortcut(source)
@ -238,4 +251,14 @@ class ExploreFragment :
.create() .create()
.show() .show()
} }
private fun uninstallExternalSource(source: ExternalMangaSource) {
val uri = Uri.fromParts("package", source.packageName, null)
val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Intent.ACTION_DELETE
} else {
Intent.ACTION_UNINSTALL_PACKAGE
}
context?.startActivity(Intent(action, uri))
}
} }

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.explore.ui package org.koitharu.kotatsu.explore.ui
import androidx.collection.LongSet
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -13,6 +14,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
@ -26,12 +28,11 @@ import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.explore.ui.model.ExploreButtons import org.koitharu.kotatsu.explore.ui.model.ExploreButtons
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
import org.koitharu.kotatsu.explore.ui.model.RecommendationsItem import org.koitharu.kotatsu.explore.ui.model.RecommendationsItem
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.model.EmptyHint import org.koitharu.kotatsu.list.ui.model.EmptyHint
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@ -108,7 +109,7 @@ class ExploreViewModel @Inject constructor(
} }
} }
fun setSourcesPinned(sources: Set<MangaSource>, isPinned: Boolean) { fun setSourcesPinned(sources: Collection<MangaSource>, isPinned: Boolean) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
sourcesRepository.setIsPinned(sources, isPinned) sourcesRepository.setIsPinned(sources, isPinned)
val message = if (sources.size == 1) { val message = if (sources.size == 1) {
@ -125,6 +126,12 @@ class ExploreViewModel @Inject constructor(
settings.closeTip(TIP_SUGGESTIONS) settings.closeTip(TIP_SUGGESTIONS)
} }
fun sourcesSnapshot(ids: LongSet): List<MangaSourceInfo> {
return content.value.mapNotNull {
(it as? MangaSourceItem)?.takeIf { x -> x.id in ids }?.source
}
}
private fun createContentFlow() = combine( private fun createContentFlow() = combine(
sourcesRepository.observeEnabledSources(), sourcesRepository.observeEnabledSources(),
getSuggestionFlow(), getSuggestionFlow(),
@ -136,7 +143,7 @@ class ExploreViewModel @Inject constructor(
}.withErrorHandling() }.withErrorHandling()
private fun buildList( private fun buildList(
sources: List<MangaSource>, sources: List<MangaSourceInfo>,
recommendation: List<Manga>, recommendation: List<Manga>,
isGrid: Boolean, isGrid: Boolean,
randomLoading: Boolean, randomLoading: Boolean,
@ -182,14 +189,15 @@ class ExploreViewModel @Inject constructor(
} }
private fun List<Manga>.toRecommendationList() = map { manga -> private fun List<Manga>.toRecommendationList() = map { manga ->
MangaListModel( MangaCompactListModel(
id = manga.id, id = manga.id,
title = manga.title, title = manga.title,
subtitle = manga.tags.joinToString { it.title }, subtitle = manga.tags.joinToString { it.title },
coverUrl = manga.coverUrl, coverUrl = manga.coverUrl,
manga = manga, manga = manga,
counter = 0, counter = 0,
progress = PROGRESS_NONE, progress = null,
isFavorite = false,
) )
} }

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.explore.ui.adapter package org.koitharu.kotatsu.explore.ui.adapter
import android.view.View import android.view.View
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
@ -9,11 +10,13 @@ import org.koitharu.kotatsu.core.model.getSummary
import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.image.AnimatedFaviconDrawable
import org.koitharu.kotatsu.core.ui.image.FaviconDrawable import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.drawableStart
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.recyclerView import org.koitharu.kotatsu.core.util.ext.recyclerView
@ -31,7 +34,7 @@ import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
import org.koitharu.kotatsu.explore.ui.model.RecommendationsItem import org.koitharu.kotatsu.explore.ui.model.RecommendationsItem
import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
fun exploreButtonsAD( fun exploreButtonsAD(
@ -63,7 +66,7 @@ fun exploreRecommendationItemAD(
{ layoutInflater, parent -> ItemRecommendationBinding.inflate(layoutInflater, parent, false) }, { layoutInflater, parent -> ItemRecommendationBinding.inflate(layoutInflater, parent, false) },
) { ) {
val adapter = BaseListAdapter<MangaListModel>() val adapter = BaseListAdapter<MangaCompactListModel>()
.addDelegate(ListItemType.MANGA_LIST, recommendationMangaItemAD(coil, itemClickListener, lifecycleOwner)) .addDelegate(ListItemType.MANGA_LIST, recommendationMangaItemAD(coil, itemClickListener, lifecycleOwner))
binding.pager.adapter = adapter binding.pager.adapter = adapter
binding.pager.recyclerView?.isNestedScrollingEnabled = false binding.pager.recyclerView?.isNestedScrollingEnabled = false
@ -78,7 +81,7 @@ fun recommendationMangaItemAD(
coil: ImageLoader, coil: ImageLoader,
itemClickListener: OnListItemClickListener<Manga>, itemClickListener: OnListItemClickListener<Manga>,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<MangaListModel, MangaListModel, ItemRecommendationMangaBinding>( ) = adapterDelegateViewBinding<MangaCompactListModel, MangaCompactListModel, ItemRecommendationMangaBinding>(
{ layoutInflater, parent -> ItemRecommendationMangaBinding.inflate(layoutInflater, parent, false) }, { layoutInflater, parent -> ItemRecommendationMangaBinding.inflate(layoutInflater, parent, false) },
) { ) {
@ -115,6 +118,7 @@ fun exploreSourceListItemAD(
) { ) {
val eventListener = AdapterDelegateClickListenerAdapter(this, listener) val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
val iconPinned = ContextCompat.getDrawable(context, R.drawable.ic_pin_small)
binding.root.setOnClickListener(eventListener) binding.root.setOnClickListener(eventListener)
binding.root.setOnLongClickListener(eventListener) binding.root.setOnLongClickListener(eventListener)
@ -122,11 +126,12 @@ fun exploreSourceListItemAD(
bind { bind {
binding.textViewTitle.text = item.source.getTitle(context) binding.textViewTitle.text = item.source.getTitle(context)
binding.textViewTitle.drawableStart = if (item.source.isPinned) iconPinned else null
binding.textViewSubtitle.text = item.source.getSummary(context) binding.textViewSubtitle.text = item.source.getSummary(context)
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name) val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run { binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
fallback(fallbackIcon) fallback(fallbackIcon)
placeholder(fallbackIcon) placeholder(AnimatedFaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name))
error(fallbackIcon) error(fallbackIcon)
source(item.source) source(item.source)
enqueueWith(coil) enqueueWith(coil)
@ -150,6 +155,7 @@ fun exploreSourceGridItemAD(
) { ) {
val eventListener = AdapterDelegateClickListenerAdapter(this, listener) val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
val iconPinned = ContextCompat.getDrawable(context, R.drawable.ic_pin_small)
binding.root.setOnClickListener(eventListener) binding.root.setOnClickListener(eventListener)
binding.root.setOnLongClickListener(eventListener) binding.root.setOnLongClickListener(eventListener)
@ -157,10 +163,11 @@ fun exploreSourceGridItemAD(
bind { bind {
binding.textViewTitle.text = item.source.getTitle(context) binding.textViewTitle.text = item.source.getTitle(context)
binding.textViewTitle.drawableStart = if (item.source.isPinned) iconPinned else null
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Large, item.source.name) val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Large, item.source.name)
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run { binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
fallback(fallbackIcon) fallback(fallbackIcon)
placeholder(fallbackIcon) placeholder(AnimatedFaviconDrawable(context, R.style.FaviconDrawable_Large, item.source.name))
error(fallbackIcon) error(fallbackIcon)
source(item.source) source(item.source)
enqueueWith(coil) enqueueWith(coil)

@ -1,15 +1,15 @@
package org.koitharu.kotatsu.explore.ui.model package org.koitharu.kotatsu.explore.ui.model
import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaSource
data class MangaSourceItem( data class MangaSourceItem(
val source: MangaSource, val source: MangaSourceInfo,
val isGrid: Boolean, val isGrid: Boolean,
) : ListModel { ) : ListModel {
val id: Long val id: Long = source.name.longHashCode()
get() = source.ordinal.toLong()
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {
return other is MangaSourceItem && other.source == source return other is MangaSourceItem && other.source == source

@ -1,10 +1,10 @@
package org.koitharu.kotatsu.explore.ui.model package org.koitharu.kotatsu.explore.ui.model
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel
data class RecommendationsItem( data class RecommendationsItem(
val manga: List<MangaListModel> val manga: List<MangaCompactListModel>
) : ListModel { ) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {

@ -115,6 +115,9 @@ abstract class FavouritesDao {
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0 ORDER BY favourites.created_at ASC") @Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0 ORDER BY favourites.created_at ASC")
abstract suspend fun findCategoriesIds(mangaIds: Collection<Long>): List<Long> abstract suspend fun findCategoriesIds(mangaIds: Collection<Long>): List<Long>
@Query("SELECT COUNT(category_id) FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0")
abstract suspend fun findCategoriesCount(mangaId: Long): Int
/** INSERT **/ /** INSERT **/
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)

@ -115,6 +115,10 @@ class FavouritesRepository @Inject constructor(
return db.getFavouriteCategoriesDao().find(id.toInt()).toFavouriteCategory() return db.getFavouriteCategoriesDao().find(id.toInt()).toFavouriteCategory()
} }
suspend fun isFavorite(mangaId: Long): Boolean {
return db.getFavouritesDao().findCategoriesCount(mangaId) != 0
}
suspend fun getCategoriesIds(mangaIds: Collection<Long>): Set<Long> { suspend fun getCategoriesIds(mangaIds: Collection<Long>): Set<Long> {
return db.getFavouritesDao().findCategoriesIds(mangaIds).toSet() return db.getFavouritesDao().findCategoriesIds(mangaIds).toSet()
} }

@ -1,12 +1,10 @@
package org.koitharu.kotatsu.favourites.domain.model package org.koitharu.kotatsu.favourites.domain.model
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.util.find
data class Cover( data class Cover(
val url: String, val url: String,
val source: String, val source: String,
) { ) {
val mangaSource: MangaSource? val mangaSource by lazy { MangaSource(source) }
get() = if (source.isEmpty()) null else MangaSource.entries.find(source)
} }

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.favourites.ui.categories package org.koitharu.kotatsu.favourites.ui.categories
import androidx.collection.LongSet
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -76,7 +77,7 @@ class FavouritesCategoriesViewModel @Inject constructor(
} }
} }
fun getCategories(ids: Set<Long>): ArrayList<FavouriteCategory> { fun getCategories(ids: LongSet): ArrayList<FavouriteCategory> {
val items = content.requireValue() val items = content.requireValue()
return items.mapNotNullTo(ArrayList(ids.size)) { item -> return items.mapNotNullTo(ArrayList(ids.size)) { item ->
(item as? CategoryListModel)?.category?.takeIf { it.id in ids } (item as? CategoryListModel)?.category?.takeIf { it.id in ids }

@ -10,13 +10,13 @@ import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource
@AndroidEntryPoint @AndroidEntryPoint
class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener { class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener {
@ -57,9 +57,7 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
} }
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { it.isLocal }
it.source == MangaSource.LOCAL
}
return super.onPrepareActionMode(controller, mode, menu) return super.onPrepareActionMode(controller, mode, menu)
} }

@ -24,13 +24,12 @@ import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
import org.koitharu.kotatsu.history.domain.MarkAsReadUseCase import org.koitharu.kotatsu.history.domain.MarkAsReadUseCase
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
@ -41,7 +40,7 @@ private const val PAGE_SIZE = 20
class FavouritesListViewModel @Inject constructor( class FavouritesListViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val repository: FavouritesRepository, private val repository: FavouritesRepository,
private val listExtraProvider: ListExtraProvider, private val mangaListMapper: MangaListMapper,
private val markAsReadUseCase: MarkAsReadUseCase, private val markAsReadUseCase: MarkAsReadUseCase,
settings: AppSettings, settings: AppSettings,
downloadScheduler: DownloadWorker.Scheduler, downloadScheduler: DownloadWorker.Scheduler,
@ -67,7 +66,7 @@ class FavouritesListViewModel @Inject constructor(
override val content = combine( override val content = combine(
observeFavorites(), observeFavorites(),
listMode, observeListModeWithTriggers(),
refreshTrigger, refreshTrigger,
) { list, mode, _ -> ) { list, mode, _ ->
when { when {
@ -86,7 +85,7 @@ class FavouritesListViewModel @Inject constructor(
else -> { else -> {
isReady.set(true) isReady.set(true)
list.toUi(mode, listExtraProvider) mangaListMapper.toListModelList(list, mode)
} }
} }
}.catch { }.catch {

@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
@ -31,7 +32,6 @@ import org.koitharu.kotatsu.core.util.LocaleComparator
import org.koitharu.kotatsu.core.util.ext.asArrayList import org.koitharu.kotatsu.core.util.ext.asArrayList
import org.koitharu.kotatsu.core.util.ext.lifecycleScope import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
@ -43,6 +43,7 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorFooter import org.koitharu.kotatsu.list.ui.model.toErrorFooter
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@ -66,7 +67,7 @@ class FilterCoordinator @Inject constructor(
) : MangaFilter { ) : MangaFilter {
private val coroutineScope = lifecycle.lifecycleScope private val coroutineScope = lifecycle.lifecycleScope
private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE)) private val repository = mangaRepositoryFactory.create(MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE]))
private val currentState = MutableStateFlow( private val currentState = MutableStateFlow(
MangaListFilter.Advanced( MangaListFilter.Advanced(
sortOrder = repository.defaultSortOrder, sortOrder = repository.defaultSortOrder,
@ -451,7 +452,7 @@ class FilterCoordinator @Inject constructor(
} }
private fun mergeTags(primary: Set<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> { private fun mergeTags(primary: Set<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> {
val result = TreeSet(TagTitleComparator(repository.source.locale)) val result = TreeSet(TagTitleComparator((repository.source as? MangaParserSource)?.locale))
result.addAll(secondary) result.addAll(secondary)
result.addAll(primary) result.addAll(primary)
return result return result

@ -88,7 +88,7 @@ abstract class HistoryDao {
abstract suspend fun insert(entity: HistoryEntity): Long abstract suspend fun insert(entity: HistoryEntity): Long
@Query( @Query(
"UPDATE history SET page = :page, chapter_id = :chapterId, scroll = :scroll, percent = :percent, updated_at = :updatedAt, deleted_at = 0 WHERE manga_id = :mangaId", "UPDATE history SET page = :page, chapter_id = :chapterId, scroll = :scroll, percent = :percent, updated_at = :updatedAt, chapters = :chapters, deleted_at = 0 WHERE manga_id = :mangaId",
) )
abstract suspend fun update( abstract suspend fun update(
mangaId: Long, mangaId: Long,
@ -96,6 +96,7 @@ abstract class HistoryDao {
chapterId: Long, chapterId: Long,
scroll: Float, scroll: Float,
percent: Float, percent: Float,
chapters: Int,
updatedAt: Long, updatedAt: Long,
): Int ): Int
@ -116,6 +117,7 @@ abstract class HistoryDao {
chapterId = entity.chapterId, chapterId = entity.chapterId,
scroll = entity.scroll, scroll = entity.scroll,
percent = entity.percent, percent = entity.percent,
chapters = entity.chaptersCount,
updatedAt = entity.updatedAt, updatedAt = entity.updatedAt,
) )

@ -19,10 +19,12 @@ import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.core.util.ext.mapItems
import org.koitharu.kotatsu.history.domain.model.MangaWithHistory import org.koitharu.kotatsu.history.domain.model.MangaWithHistory
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
@ -102,6 +104,7 @@ class HistoryRepository @Inject constructor(
assert(manga.chapters != null) assert(manga.chapters != null)
db.withTransaction { db.withTransaction {
mangaRepository.storeManga(manga) mangaRepository.storeManga(manga)
val branch = manga.chapters?.findById(chapterId)?.branch
db.getHistoryDao().upsert( db.getHistoryDao().upsert(
HistoryEntity( HistoryEntity(
mangaId = manga.id, mangaId = manga.id,
@ -111,7 +114,7 @@ class HistoryRepository @Inject constructor(
page = page, page = page,
scroll = scroll.toFloat(), // we migrate to int, but decide to not update database scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
percent = percent, percent = percent,
chaptersCount = manga.chapters?.size ?: -1, chaptersCount = manga.chapters?.count { it.branch == branch } ?: 0,
deletedAt = 0L, deletedAt = 0L,
), ),
) )
@ -124,8 +127,13 @@ class HistoryRepository @Inject constructor(
return db.getHistoryDao().find(manga.id)?.recoverIfNeeded(manga)?.toMangaHistory() return db.getHistoryDao().find(manga.id)?.recoverIfNeeded(manga)?.toMangaHistory()
} }
suspend fun getProgress(mangaId: Long): Float { suspend fun getProgress(mangaId: Long, mode: ProgressIndicatorMode): ReadingProgress? {
return db.getHistoryDao().findProgress(mangaId) ?: PROGRESS_NONE val entity = db.getHistoryDao().find(mangaId) ?: return null
return ReadingProgress(
percent = entity.percent,
totalChapters = entity.chaptersCount,
mode = mode,
).takeIf { it.isValid() }
} }
suspend fun clear() { suspend fun clear() {

@ -8,6 +8,7 @@ import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.os.NetworkManageIntent import org.koitharu.kotatsu.core.os.NetworkManageIntent
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
@ -17,7 +18,6 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
import org.koitharu.kotatsu.parsers.model.MangaSource
@AndroidEntryPoint @AndroidEntryPoint
class HistoryListFragment : MangaListFragment() { class HistoryListFragment : MangaListFragment() {
@ -44,9 +44,7 @@ class HistoryListFragment : MangaListFragment() {
} }
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { it.isLocal }
it.source == MangaSource.LOCAL
}
return super.onPrepareActionMode(controller, mode, menu) return super.onPrepareActionMode(controller, mode, menu)
} }

@ -28,8 +28,8 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.domain.MarkAsReadUseCase import org.koitharu.kotatsu.history.domain.MarkAsReadUseCase
import org.koitharu.kotatsu.history.domain.model.MangaWithHistory import org.koitharu.kotatsu.history.domain.model.MangaWithHistory
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyHint import org.koitharu.kotatsu.list.ui.model.EmptyHint
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
@ -38,9 +38,6 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.TipModel import org.koitharu.kotatsu.list.ui.model.TipModel
import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toGridModel
import org.koitharu.kotatsu.list.ui.model.toListDetailedModel
import org.koitharu.kotatsu.list.ui.model.toListModel
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.time.Instant import java.time.Instant
@ -53,7 +50,7 @@ private const val PAGE_SIZE = 20
class HistoryListViewModel @Inject constructor( class HistoryListViewModel @Inject constructor(
private val repository: HistoryRepository, private val repository: HistoryRepository,
settings: AppSettings, settings: AppSettings,
private val extraProvider: ListExtraProvider, private val mangaListMapper: MangaListMapper,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val markAsReadUseCase: MarkAsReadUseCase, private val markAsReadUseCase: MarkAsReadUseCase,
networkState: NetworkState, networkState: NetworkState,
@ -91,7 +88,7 @@ class HistoryListViewModel @Inject constructor(
override val content = combine( override val content = combine(
observeHistory(), observeHistory(),
isGroupingEnabled, isGroupingEnabled,
listMode, observeListModeWithTriggers(),
networkState, networkState,
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled }, settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled },
) { list, grouped, mode, online, incognito -> ) { list, grouped, mode, online, incognito ->
@ -203,11 +200,7 @@ class HistoryListViewModel @Inject constructor(
prevHeader = header prevHeader = header
} }
} }
result += when (mode) { result += mangaListMapper.toListModel(manga, mode)
ListMode.LIST -> manga.toListModel(extraProvider)
ListMode.DETAILED_LIST -> manga.toListDetailedModel(extraProvider)
ListMode.GRID -> manga.toGridModel(extraProvider)
}
} }
return result return result
} }

@ -26,7 +26,6 @@ class ReadingProgressDrawable(
private val outlineColor: Int private val outlineColor: Int
private val backgroundColor: Int private val backgroundColor: Int
private val textColor: Int private val textColor: Int
private val textPattern = context.getString(R.string.percent_string_pattern)
private val textBounds = Rect() private val textBounds = Rect()
private val tempRect = Rect() private val tempRect = Rect()
private val hasBackground: Boolean private val hasBackground: Boolean
@ -36,14 +35,18 @@ class ReadingProgressDrawable(
private val desiredWidth: Int private val desiredWidth: Int
private val autoFitTextSize: Boolean private val autoFitTextSize: Boolean
var progress: Float = PROGRESS_NONE var percent: Float = PROGRESS_NONE
set(value) {
field = value
invalidateSelf()
}
var text = ""
set(value) { set(value) {
field = value field = value
text = textPattern.format((value * 100f).toInt().toString())
paint.getTextBounds(text, 0, text.length, textBounds) paint.getTextBounds(text, 0, text.length, textBounds)
invalidateSelf() invalidateSelf()
} }
private var text = ""
init { init {
val ta = context.obtainStyledAttributes(styleResId, R.styleable.ProgressDrawable) val ta = context.obtainStyledAttributes(styleResId, R.styleable.ProgressDrawable)
@ -79,7 +82,7 @@ class ReadingProgressDrawable(
} }
override fun draw(canvas: Canvas) { override fun draw(canvas: Canvas) {
if (progress < 0f) { if (percent < 0f) {
return return
} }
val cx = bounds.exactCenterX() val cx = bounds.exactCenterX()
@ -103,12 +106,12 @@ class ReadingProgressDrawable(
cx + innerRadius, cx + innerRadius,
cy + innerRadius, cy + innerRadius,
-90f, -90f,
360f * progress, 360f * percent,
false, false,
paint, paint,
) )
if (hasText) { if (hasText) {
if (checkDrawable != null && progress >= 1f - Math.ulp(progress)) { if (checkDrawable != null && percent >= 1f - Math.ulp(percent)) {
tempRect.set(bounds) tempRect.set(bounds)
tempRect.scale(0.6) tempRect.scale(0.6)
checkDrawable.bounds = tempRect checkDrawable.bounds = tempRect

@ -11,8 +11,14 @@ import android.view.animation.AccelerateDecelerateInterpolator
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.StyleRes import androidx.annotation.StyleRes
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.CHAPTERS_LEFT
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.CHAPTERS_READ
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.NONE
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.PERCENT_LEFT
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.PERCENT_READ
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.history.data.PROGRESS_NONE import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ReadingProgress
class ReadingProgressView @JvmOverloads constructor( class ReadingProgressView @JvmOverloads constructor(
context: Context, context: Context,
@ -20,17 +26,30 @@ class ReadingProgressView @JvmOverloads constructor(
@AttrRes defStyleAttr: Int = 0, @AttrRes defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener, Animator.AnimatorListener { ) : View(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener, Animator.AnimatorListener {
private val percentPattern = context.getString(R.string.percent_string_pattern)
private var percentAnimator: ValueAnimator? = null private var percentAnimator: ValueAnimator? = null
private val animationDuration = context.getAnimationDuration(android.R.integer.config_shortAnimTime) private val animationDuration = context.getAnimationDuration(android.R.integer.config_shortAnimTime)
@StyleRes @StyleRes
private val drawableStyle: Int private val drawableStyle: Int
var percent: Float var progress: ReadingProgress? = null
get() = peekProgressDrawable()?.progress ?: PROGRESS_NONE
set(value) { set(value) {
field = value
cancelAnimation() cancelAnimation()
getProgressDrawable().progress = value getProgressDrawable().also {
it.percent = value?.percent ?: PROGRESS_NONE
it.text = when (value?.mode) {
null,
NONE -> ""
PERCENT_READ -> percentPattern.format((value.percent * 100f).toInt().toString())
PERCENT_LEFT -> "-" + percentPattern.format((value.percentLeft * 100f).toInt().toString())
CHAPTERS_READ -> value.chapters.toString()
CHAPTERS_LEFT -> "-" + value.chaptersLeft.toString()
}
}
} }
init { init {
@ -39,7 +58,11 @@ class ReadingProgressView @JvmOverloads constructor(
ta.recycle() ta.recycle()
outlineProvider = OutlineProvider() outlineProvider = OutlineProvider()
if (isInEditMode) { if (isInEditMode) {
percent = 0.27f progress = ReadingProgress(
percent = 0.27f,
totalChapters = 20,
mode = PERCENT_READ,
)
} }
} }
@ -53,7 +76,7 @@ class ReadingProgressView @JvmOverloads constructor(
override fun onAnimationUpdate(animation: ValueAnimator) { override fun onAnimationUpdate(animation: ValueAnimator) {
val p = animation.animatedValue as Float val p = animation.animatedValue as Float
getProgressDrawable().progress = p getProgressDrawable().percent = p
} }
override fun onAnimationStart(animation: Animator) = Unit override fun onAnimationStart(animation: Animator) = Unit
@ -68,16 +91,25 @@ class ReadingProgressView @JvmOverloads constructor(
override fun onAnimationRepeat(animation: Animator) = Unit override fun onAnimationRepeat(animation: Animator) = Unit
fun setPercent(value: Float, animate: Boolean) { fun setProgress(percent: Float, animate: Boolean) {
setProgress(
value = ReadingProgress(percent, 1, PERCENT_READ),
animate = animate,
)
}
fun setProgress(value: ReadingProgress?, animate: Boolean) {
val currentDrawable = peekProgressDrawable() val currentDrawable = peekProgressDrawable()
if (!animate || currentDrawable == null || value == PROGRESS_NONE) { if (!animate || currentDrawable == null || value == null) {
percent = value progress = value
return return
} }
percentAnimator?.cancel() percentAnimator?.cancel()
val currentPercent = currentDrawable.percent.coerceAtLeast(0f)
progress = value.copy(percent = currentPercent)
percentAnimator = ValueAnimator.ofFloat( percentAnimator = ValueAnimator.ofFloat(
currentDrawable.progress.coerceAtLeast(0f), currentDrawable.percent.coerceAtLeast(0f),
value, value.percent,
).apply { ).apply {
duration = animationDuration duration = animationDuration
interpolator = AccelerateDecelerateInterpolator() interpolator = AccelerateDecelerateInterpolator()

@ -25,13 +25,13 @@ import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.util.PopupMenuMediator import org.koitharu.kotatsu.core.ui.util.PopupMenuMediator
import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getDisplayIcon import org.koitharu.kotatsu.core.util.ext.getDisplayIcon
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
@ -120,7 +120,7 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
.memoryCachePolicy(CachePolicy.DISABLED) .memoryCachePolicy(CachePolicy.DISABLED)
.lifecycle(this) .lifecycle(this)
.listener(this) .listener(this)
.source(intent.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)) .source(MangaSource(intent.getStringExtra(EXTRA_SOURCE)))
.target(SsivTarget(viewBinding.ssiv)) .target(SsivTarget(viewBinding.ssiv))
.enqueueWith(coil) .enqueueWith(coil)
} }
@ -180,7 +180,7 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
fun newIntent(context: Context, url: String, source: MangaSource?): Intent { fun newIntent(context: Context, url: String, source: MangaSource?): Intent {
return Intent(context, ImageActivity::class.java) return Intent(context, ImageActivity::class.java)
.setData(Uri.parse(url)) .setData(Uri.parse(url))
.putExtra(EXTRA_SOURCE, source) .putExtra(EXTRA_SOURCE, source?.name)
} }
} }
} }

@ -1,60 +0,0 @@
package org.koitharu.kotatsu.list.domain
import android.content.Context
import androidx.annotation.ColorRes
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import javax.inject.Inject
@Reusable
class ListExtraProvider @Inject constructor(
@ApplicationContext context: Context,
private val settings: AppSettings,
private val trackingRepository: TrackingRepository,
private val historyRepository: HistoryRepository,
) {
private val dict by lazy {
context.resources.openRawResource(R.raw.tags_redlist).use {
val set = HashSet<String>()
it.bufferedReader().forEachLine { x ->
val line = x.trim()
if (line.isNotEmpty()) {
set.add(line)
}
}
set
}
}
suspend fun getCounter(mangaId: Long): Int {
return if (settings.isTrackerEnabled) {
trackingRepository.getNewChaptersCount(mangaId)
} else {
0
}
}
suspend fun getProgress(mangaId: Long): Float {
return if (settings.isReadingIndicatorsEnabled) {
historyRepository.getProgress(mangaId)
} else {
PROGRESS_NONE
}
}
@ColorRes
fun getTagTint(tag: MangaTag): Int {
return if (tag.title.lowercase() in dict) {
R.color.warning
} else {
0
}
}
}

@ -0,0 +1,131 @@
package org.koitharu.kotatsu.list.domain
import android.content.Context
import androidx.annotation.ColorRes
import androidx.collection.MutableScatterSet
import androidx.collection.ScatterSet
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel
import org.koitharu.kotatsu.list.ui.model.MangaDetailedListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MangaListMapper @Inject constructor(
@ApplicationContext context: Context,
private val settings: AppSettings,
private val trackingRepository: TrackingRepository,
private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository,
) {
private val dict by lazy { readTagsDict(context) }
suspend fun toListModelList(manga: Collection<Manga>, mode: ListMode): List<MangaListModel> = manga.map {
toListModel(it, mode)
}
suspend fun toListModelList(
destination: MutableCollection<in MangaListModel>,
manga: Collection<Manga>,
mode: ListMode
) = manga.mapTo(destination) {
toListModel(it, mode)
}
suspend fun toListModel(manga: Manga, mode: ListMode): MangaListModel = when (mode) {
ListMode.LIST -> toCompactListModel(manga)
ListMode.DETAILED_LIST -> toDetailedListModel(manga)
ListMode.GRID -> toGridModel(manga)
}
suspend fun toCompactListModel(manga: Manga) = MangaCompactListModel(
id = manga.id,
title = manga.title,
subtitle = manga.tags.joinToString(", ") { it.title },
coverUrl = manga.coverUrl,
manga = manga,
counter = getCounter(manga.id),
progress = getProgress(manga.id),
isFavorite = isFavorite(manga.id),
)
suspend fun toDetailedListModel(manga: Manga) = MangaDetailedListModel(
id = manga.id,
title = manga.title,
subtitle = manga.altTitle,
coverUrl = manga.coverUrl,
manga = manga,
counter = getCounter(manga.id),
progress = getProgress(manga.id),
isFavorite = isFavorite(manga.id),
tags = mapTags(manga.tags),
)
suspend fun toGridModel(manga: Manga) = MangaGridModel(
id = manga.id,
title = manga.title,
coverUrl = manga.coverUrl,
manga = manga,
counter = getCounter(manga.id),
progress = getProgress(manga.id),
isFavorite = isFavorite(manga.id),
)
fun mapTags(tags: Collection<MangaTag>) = tags.map {
ChipsView.ChipModel(
tint = getTagTint(it),
title = it.title,
data = it,
)
}
private suspend fun getCounter(mangaId: Long): Int {
return if (settings.isTrackerEnabled) {
trackingRepository.getNewChaptersCount(mangaId)
} else {
0
}
}
private suspend fun getProgress(mangaId: Long): ReadingProgress? {
return historyRepository.getProgress(mangaId, settings.progressIndicatorMode)
}
private fun isFavorite(mangaId: Long): Boolean {
return false // TODO favouritesRepository.isFavorite(mangaId)
}
@ColorRes
private fun getTagTint(tag: MangaTag): Int {
return if (tag.title.lowercase() in dict) {
R.color.warning
} else {
0
}
}
private fun readTagsDict(context: Context): ScatterSet<String> =
context.resources.openRawResource(R.raw.tags_redlist).use {
val set = MutableScatterSet<String>()
it.bufferedReader().forEachLine { x ->
val line = x.trim()
if (line.isNotEmpty()) {
set.add(line)
}
}
set.trim()
set
}
}

@ -0,0 +1,35 @@
package org.koitharu.kotatsu.list.domain
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.CHAPTERS_LEFT
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.CHAPTERS_READ
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.NONE
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.PERCENT_LEFT
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.PERCENT_READ
data class ReadingProgress(
val percent: Float,
val totalChapters: Int,
val mode: ProgressIndicatorMode,
) {
val percentLeft: Float
get() = 1f - percent
val chapters: Int
get() = (totalChapters * percent).toInt()
val chaptersLeft: Int
get() = (totalChapters * percentLeft).toInt()
fun isValid() = when (mode) {
NONE -> false
PERCENT_READ,
PERCENT_LEFT -> percent in 0f..1f
CHAPTERS_READ,
CHAPTERS_LEFT -> totalChapters > 0 && percent in 0f..1f
}
fun isReversed() = mode == PERCENT_LEFT || mode == CHAPTERS_LEFT
}

@ -20,7 +20,6 @@ import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@ -51,7 +50,7 @@ import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
import org.koitharu.kotatsu.main.ui.MainActivity import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
@ -281,7 +280,7 @@ abstract class MangaListFragment :
return when (item.itemId) { return when (item.itemId) {
R.id.action_select_all -> { R.id.action_select_all -> {
val ids = listAdapter?.items?.mapNotNull { val ids = listAdapter?.items?.mapNotNull {
(it as? MangaItemModel)?.id (it as? MangaListModel)?.id
} ?: return false } ?: return false
selectionController?.addAll(ids) selectionController?.addAll(ids)
true true
@ -327,7 +326,7 @@ abstract class MangaListFragment :
val items = listAdapter?.items ?: return emptySet() val items = listAdapter?.items ?: return emptySet()
val result = ArraySet<Manga>(checkedIds.size) val result = ArraySet<Manga>(checkedIds.size)
for (item in items) { for (item in items) {
if (item is MangaItemModel && item.id in checkedIds) { if (item is MangaListModel && item.id in checkedIds) {
result.add(item.manga) result.add(item.manga)
} }
} }

@ -2,11 +2,16 @@ package org.koitharu.kotatsu.list.ui
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
@ -55,4 +60,13 @@ abstract class MangaListViewModel(
} else { } else {
this this
} }
protected fun observeListModeWithTriggers(): Flow<ListMode> = combine(
listMode,
settings.observe().filter { key ->
key == AppSettings.KEY_PROGRESS_INDICATORS || key == AppSettings.KEY_TRACKER_ENABLED
}.onStart { emit("") }
) { mode, _ ->
mode
}
} }

@ -14,7 +14,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.core.util.ext.getItem import org.koitharu.kotatsu.core.util.ext.getItem
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.list.ui.model.MangaItemModel import org.koitharu.kotatsu.list.ui.model.MangaListModel
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
@ -37,7 +37,7 @@ open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDec
override fun getItemId(parent: RecyclerView, child: View): Long { override fun getItemId(parent: RecyclerView, child: View): Long {
val holder = parent.getChildViewHolder(child) ?: return NO_ID val holder = parent.getChildViewHolder(child) ?: return NO_ID
val item = holder.getItem(MangaItemModel::class.java) ?: return NO_ID val item = holder.getItem(MangaListModel::class.java) ?: return NO_ID
return item.id return item.id
} }

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.list.ui.adapter package org.koitharu.kotatsu.list.ui.adapter
import android.view.View import android.view.View
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
@ -14,7 +15,7 @@ import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemMangaGridBinding import org.koitharu.kotatsu.databinding.ItemMangaGridBinding
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver
@ -41,7 +42,8 @@ fun mangaGridItemAD(
bind { payloads -> bind { payloads ->
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads) binding.progressView.setProgress(item.progress, PAYLOAD_PROGRESS_CHANGED in payloads)
binding.imageViewFavorite.isVisible = item.isFavorite
binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run { binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run {
size(CoverSizeResolver(binding.imageViewCover)) size(CoverSizeResolver(binding.imageViewCover))
defaultPlaceholders(context) defaultPlaceholders(context)

@ -16,13 +16,13 @@ import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel import org.koitharu.kotatsu.list.ui.model.MangaDetailedListModel
fun mangaListDetailedItemAD( fun mangaListDetailedItemAD(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
clickListener: MangaDetailsClickListener, clickListener: MangaDetailsClickListener,
) = adapterDelegateViewBinding<MangaListDetailedModel, ListModel, ItemMangaListDetailsBinding>( ) = adapterDelegateViewBinding<MangaDetailedListModel, ListModel, ItemMangaListDetailsBinding>(
{ inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) }, { inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) },
) { ) {
var badge: BadgeDrawable? = null var badge: BadgeDrawable? = null
@ -39,7 +39,10 @@ fun mangaListDetailedItemAD(
bind { payloads -> bind { payloads ->
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title
binding.textViewAuthor.textAndVisible = item.manga.author binding.textViewAuthor.textAndVisible = item.manga.author
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads) binding.progressView.setProgress(
value = item.progress,
animate = ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads,
)
binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run { binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run {
size(CoverSizeResolver(binding.imageViewCover)) size(CoverSizeResolver(binding.imageViewCover))
defaultPlaceholders(context) defaultPlaceholders(context)

@ -15,14 +15,14 @@ import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemMangaListBinding import org.koitharu.kotatsu.databinding.ItemMangaListBinding
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
fun mangaListItemAD( fun mangaListItemAD(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga>, clickListener: OnListItemClickListener<Manga>,
) = adapterDelegateViewBinding<MangaListModel, ListModel, ItemMangaListBinding>( ) = adapterDelegateViewBinding<MangaCompactListModel, ListModel, ItemMangaListBinding>(
{ inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) }, { inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) },
) { ) {
var badge: BadgeDrawable? = null var badge: BadgeDrawable? = null

@ -3,74 +3,8 @@ package org.koitharu.kotatsu.list.ui.model
import androidx.annotation.StringRes import androidx.annotation.StringRes
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.getDisplayIcon import org.koitharu.kotatsu.core.util.ext.getDisplayIcon
import org.koitharu.kotatsu.core.util.ext.ifZero import org.koitharu.kotatsu.core.util.ext.ifZero
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.parsers.model.Manga
suspend fun Manga.toListModel(
extraProvider: ListExtraProvider?
) = MangaListModel(
id = id,
title = title,
subtitle = tags.joinToString(", ") { it.title },
coverUrl = coverUrl,
manga = this,
counter = extraProvider?.getCounter(id) ?: 0,
progress = extraProvider?.getProgress(id) ?: PROGRESS_NONE,
)
suspend fun Manga.toListDetailedModel(
extraProvider: ListExtraProvider?,
) = MangaListDetailedModel(
id = id,
title = title,
subtitle = altTitle,
coverUrl = coverUrl,
manga = this,
counter = extraProvider?.getCounter(id) ?: 0,
progress = extraProvider?.getProgress(id) ?: PROGRESS_NONE,
tags = tags.map {
ChipsView.ChipModel(
tint = extraProvider?.getTagTint(it) ?: 0,
title = it.title,
data = it,
)
},
)
suspend fun Manga.toGridModel(
extraProvider: ListExtraProvider?,
) = MangaGridModel(
id = id,
title = title,
coverUrl = coverUrl,
manga = this,
counter = extraProvider?.getCounter(id) ?: 0,
progress = extraProvider?.getProgress(id) ?: PROGRESS_NONE,
)
suspend fun List<Manga>.toUi(
mode: ListMode,
extraProvider: ListExtraProvider,
): List<MangaItemModel> = if (isEmpty()) {
emptyList()
} else {
toUi(ArrayList(size), mode, extraProvider)
}
suspend fun <C : MutableCollection<in MangaItemModel>> List<Manga>.toUi(
destination: C,
mode: ListMode,
extraProvider: ListExtraProvider,
): C = when (mode) {
ListMode.LIST -> mapTo(destination) { it.toListModel(extraProvider) }
ListMode.DETAILED_LIST -> mapTo(destination) { it.toListDetailedModel(extraProvider) }
ListMode.GRID -> mapTo(destination) { it.toGridModel(extraProvider) }
}
fun Throwable.toErrorState(canRetry: Boolean = true, @StringRes secondaryAction: Int = 0) = ErrorState( fun Throwable.toErrorState(canRetry: Boolean = true, @StringRes secondaryAction: Int = 0) = ErrorState(
exception = this, exception = this,

@ -0,0 +1,15 @@
package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.parsers.model.Manga
data class MangaCompactListModel(
override val id: Long,
override val title: String,
val subtitle: String,
override val coverUrl: String,
override val manga: Manga,
override val counter: Int,
override val progress: ReadingProgress?,
override val isFavorite: Boolean,
) : MangaListModel()

@ -1,15 +1,17 @@
package org.koitharu.kotatsu.list.ui.model package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
data class MangaListDetailedModel( data class MangaDetailedListModel(
override val id: Long, override val id: Long,
override val title: String, override val title: String,
val subtitle: String?, val subtitle: String?,
override val coverUrl: String, override val coverUrl: String,
override val manga: Manga, override val manga: Manga,
override val counter: Int, override val counter: Int,
override val progress: Float, override val progress: ReadingProgress?,
override val isFavorite: Boolean,
val tags: List<ChipsView.ChipModel>, val tags: List<ChipsView.ChipModel>,
) : MangaItemModel() ) : MangaListModel()

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.list.ui.model package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
data class MangaGridModel( data class MangaGridModel(
@ -8,5 +9,6 @@ data class MangaGridModel(
override val coverUrl: String, override val coverUrl: String,
override val manga: Manga, override val manga: Manga,
override val counter: Int, override val counter: Int,
override val progress: Float, override val progress: ReadingProgress?,
) : MangaItemModel() override val isFavorite: Boolean,
) : MangaListModel()

@ -1,31 +0,0 @@
package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
sealed class MangaItemModel : ListModel {
abstract val id: Long
abstract val manga: Manga
abstract val title: String
abstract val coverUrl: String
abstract val counter: Int
abstract val progress: Float
val source: MangaSource
get() = manga.source
override fun areItemsTheSame(other: ListModel): Boolean {
return other is MangaItemModel && other.javaClass == javaClass && id == other.id
}
override fun getChangePayload(previousState: ListModel): Any? {
return when {
previousState !is MangaItemModel -> super.getChangePayload(previousState)
progress != previousState.progress -> ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED
counter != previousState.counter -> ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED
else -> null
}
}
}

@ -1,13 +1,34 @@
package org.koitharu.kotatsu.list.ui.model package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
data class MangaListModel( sealed class MangaListModel : ListModel {
override val id: Long,
override val title: String, abstract val id: Long
val subtitle: String, abstract val manga: Manga
override val coverUrl: String, abstract val title: String
override val manga: Manga, abstract val coverUrl: String
override val counter: Int, abstract val counter: Int
override val progress: Float, abstract val isFavorite: Boolean
) : MangaItemModel() abstract val progress: ReadingProgress?
val source: MangaSource
get() = manga.source
override fun areItemsTheSame(other: ListModel): Boolean {
return other is MangaListModel && other.javaClass == javaClass && id == other.id
}
override fun getChangePayload(previousState: ListModel): Any? = when {
previousState !is MangaListModel || previousState.manga != manga -> null
previousState.progress != progress -> PAYLOAD_PROGRESS_CHANGED
previousState.isFavorite != isFavorite || previousState.counter != counter -> PAYLOAD_ANYTHING_CHANGED
else -> null
}
}

@ -25,18 +25,17 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.data.PROGRESS_NONE import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.MangaListMapper
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class PreviewViewModel @Inject constructor( class PreviewViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val extraProvider: ListExtraProvider, private val mangaListMapper: MangaListMapper,
private val repositoryFactory: MangaRepository.Factory, private val repositoryFactory: MangaRepository.Factory,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val imageGetter: Html.ImageGetter, private val imageGetter: Html.ImageGetter,
@ -81,13 +80,7 @@ class PreviewViewModel @Inject constructor(
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), null) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), null)
val tagsChips = manga.map { val tagsChips = manga.map {
it.tags.map { tag -> mangaListMapper.mapTags(it.tags)
ChipsView.ChipModel(
title = tag.title,
tint = extraProvider.getTagTint(tag),
data = tag,
)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
init { init {

@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@ -29,7 +30,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@ -49,7 +49,7 @@ class LocalMangaRepository @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
) : MangaRepository { ) : MangaRepository {
override val source = MangaSource.LOCAL override val source = LocalMangaSource
private val locks = MultiMutex<Long>() private val locks = MultiMutex<Long>()
private val localMappingCache = LocalMangaMappingCache() private val localMappingCache = LocalMangaMappingCache()
@ -100,7 +100,7 @@ class LocalMangaRepository @Inject constructor(
} }
override suspend fun getDetails(manga: Manga): Manga = when { override suspend fun getDetails(manga: Manga): Manga = when {
manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)?.manga) { !manga.isLocal -> requireNotNull(findSavedManga(manga)?.manga) {
"Manga is not local or saved" "Manga is not local or saved"
} }

@ -4,6 +4,7 @@ import androidx.annotation.WorkerThread
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
@ -58,7 +59,7 @@ class MangaIndex(source: String?) {
} }
fun getMangaInfo(): Manga? = if (json.length() == 0) null else runCatching { fun getMangaInfo(): Manga? = if (json.length() == 0) null else runCatching {
val source = MangaSource.valueOf(json.getString("source")) val source = MangaSource(json.getString("source"))
Manga( Manga(
id = json.getLong("id"), id = json.getLong("id"),
title = json.getString("title"), title = json.getString("title"),

@ -4,6 +4,7 @@ import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.util.AlphanumComparator import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.children import org.koitharu.kotatsu.core.util.ext.children
import org.koitharu.kotatsu.core.util.ext.creationTime import org.koitharu.kotatsu.core.util.ext.creationTime
@ -18,7 +19,6 @@ import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toCamelCase import org.koitharu.kotatsu.parsers.util.toCamelCase
import java.io.File import java.io.File
import java.util.TreeMap import java.util.TreeMap
@ -47,7 +47,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
index?.getCoverEntry() ?: findFirstImageEntry().orEmpty(), index?.getCoverEntry() ?: findFirstImageEntry().orEmpty(),
) )
val manga = info?.copy2( val manga = info?.copy2(
source = MangaSource.LOCAL, source = LocalMangaSource,
url = mangaUri, url = mangaUri,
coverUrl = cover, coverUrl = cover,
largeCoverUrl = cover, largeCoverUrl = cover,
@ -59,14 +59,14 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
// old downloads // old downloads
chapterFiles.values.elementAtOrNull(i) chapterFiles.values.elementAtOrNull(i)
} ?: return@mapIndexedNotNull null } ?: return@mapIndexedNotNull null
c.copy(url = file.toUri().toString(), source = MangaSource.LOCAL) c.copy(url = file.toUri().toString(), source = LocalMangaSource)
}, },
) ?: Manga( ) ?: Manga(
id = root.absolutePath.longHashCode(), id = root.absolutePath.longHashCode(),
title = root.name.toHumanReadable(), title = root.name.toHumanReadable(),
url = mangaUri, url = mangaUri,
publicUrl = mangaUri, publicUrl = mangaUri,
source = MangaSource.LOCAL, source = LocalMangaSource,
coverUrl = findFirstImageEntry().orEmpty(), coverUrl = findFirstImageEntry().orEmpty(),
chapters = chapterFiles.values.mapIndexed { i, f -> chapters = chapterFiles.values.mapIndexed { i, f ->
MangaChapter( MangaChapter(
@ -74,7 +74,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
name = f.nameWithoutExtension.toHumanReadable(), name = f.nameWithoutExtension.toHumanReadable(),
number = 0f, number = 0f,
volume = 0, volume = 0,
source = MangaSource.LOCAL, source = LocalMangaSource,
uploadDate = f.creationTime, uploadDate = f.creationTime,
url = f.toUri().toString(), url = f.toUri().toString(),
scanlator = null, scanlator = null,
@ -106,7 +106,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name }) .toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
.map { .map {
val pageUri = it.toUri().toString() val pageUri = it.toUri().toString()
MangaPage(pageUri.longHashCode(), pageUri, null, MangaSource.LOCAL) MangaPage(pageUri.longHashCode(), pageUri, null, LocalMangaSource)
} }
} else { } else {
ZipFile(file).use { zip -> ZipFile(file).use { zip ->
@ -121,7 +121,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
id = pageUri.longHashCode(), id = pageUri.longHashCode(),
url = pageUri, url = pageUri,
preview = null, preview = null,
source = MangaSource.LOCAL, source = LocalMangaSource,
) )
} }
} }

@ -7,6 +7,7 @@ import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.util.AlphanumComparator import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.longHashCode import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.core.util.ext.readText import org.koitharu.kotatsu.core.util.ext.readText
@ -17,7 +18,6 @@ import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toCamelCase import org.koitharu.kotatsu.parsers.util.toCamelCase
import java.io.File import java.io.File
import java.util.Enumeration import java.util.Enumeration
@ -47,12 +47,12 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(), entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
) )
return@use info.copy2( return@use info.copy2(
source = MangaSource.LOCAL, source = LocalMangaSource,
url = fileUri, url = fileUri,
coverUrl = cover, coverUrl = cover,
largeCoverUrl = cover, largeCoverUrl = cover,
chapters = info.chapters?.map { c -> chapters = info.chapters?.map { c ->
c.copy(url = fileUri, source = MangaSource.LOCAL) c.copy(url = fileUri, source = LocalMangaSource)
}, },
) )
} }
@ -70,7 +70,7 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
title = title, title = title,
url = fileUri, url = fileUri,
publicUrl = fileUri, publicUrl = fileUri,
source = MangaSource.LOCAL, source = LocalMangaSource,
coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()), coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()),
chapters = chapters.sortedWith(AlphanumComparator()) chapters = chapters.sortedWith(AlphanumComparator())
.mapIndexed { i, s -> .mapIndexed { i, s ->
@ -79,7 +79,7 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
name = s.ifEmpty { title }, name = s.ifEmpty { title },
number = 0f, number = 0f,
volume = 0, volume = 0,
source = MangaSource.LOCAL, source = LocalMangaSource,
uploadDate = 0L, uploadDate = 0L,
url = uriBuilder.fragment(s).build().toString(), url = uriBuilder.fragment(s).build().toString(),
scanlator = null, scanlator = null,
@ -135,7 +135,7 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
id = entryUri.longHashCode(), id = entryUri.longHashCode(),
url = entryUri, url = entryUri,
preview = null, preview = null,
source = MangaSource.LOCAL, source = LocalMangaSource,
) )
} }
} }

@ -4,17 +4,15 @@ import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
class LocalMangaUtil( class LocalMangaUtil(
private val manga: Manga, private val manga: Manga,
) { ) {
init { init {
require(manga.source == MangaSource.LOCAL) { require(manga.isLocal) { "Expected LOCAL source but ${manga.source} found" }
"Expected LOCAL source but ${manga.source} found"
}
} }
suspend fun deleteChapters(ids: Set<Long>) { suspend fun deleteChapters(ids: Set<Long>) {

@ -14,6 +14,7 @@ import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.widgets.TipView import org.koitharu.kotatsu.core.ui.widgets.TipView
import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ShareHelper
@ -26,7 +27,6 @@ import org.koitharu.kotatsu.filter.ui.FilterOwner
import org.koitharu.kotatsu.filter.ui.MangaFilter import org.koitharu.kotatsu.filter.ui.MangaFilter
import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract
import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
@ -47,9 +47,9 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
init { init {
withArgs(1) { withArgs(1) {
putSerializable( putString(
RemoteListFragment.ARG_SOURCE, RemoteListFragment.ARG_SOURCE,
MangaSource.LOCAL, LocalMangaSource.name,
) // required by FilterCoordinator ) // required by FilterCoordinator
} }
} }

@ -16,10 +16,10 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.list.ui.model.TipModel import org.koitharu.kotatsu.list.ui.model.TipModel
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
@ -35,7 +35,7 @@ class LocalListViewModel @Inject constructor(
filter: FilterCoordinator, filter: FilterCoordinator,
private val settings: AppSettings, private val settings: AppSettings,
downloadScheduler: DownloadWorker.Scheduler, downloadScheduler: DownloadWorker.Scheduler,
listExtraProvider: ListExtraProvider, mangaListMapper: MangaListMapper,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
exploreRepository: ExploreRepository, exploreRepository: ExploreRepository,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>, @LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
@ -46,7 +46,7 @@ class LocalListViewModel @Inject constructor(
mangaRepositoryFactory, mangaRepositoryFactory,
filter, filter,
settings, settings,
listExtraProvider, mangaListMapper,
downloadScheduler, downloadScheduler,
exploreRepository, exploreRepository,
sourcesRepository, sourcesRepository,
@ -70,7 +70,7 @@ class LocalListViewModel @Inject constructor(
return return
} }
for (item in list) { for (item in list) {
if (item !is MangaItemModel) { if (item !is MangaListModel) {
continue continue
} }
val file = item.manga.url.toUriOrNull()?.toFileOrNull() ?: continue val file = item.manga.url.toUriOrNull()?.toFileOrNull() ?: continue

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

Loading…
Cancel
Save