Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
597abdb20c | ||
|
|
809e7d8701 | ||
|
|
0015c5704a | ||
|
|
a7ff1610eb | ||
|
|
22c402fc5e | ||
|
|
6e92d46a63 | ||
|
|
66ed926ea8 | ||
|
|
b7741ce2af | ||
|
|
1a17324d26 | ||
|
|
4044936481 | ||
|
|
1efe86421a | ||
|
|
34dd080f6c | ||
|
|
f4838afab0 | ||
|
|
b207eebe56 | ||
|
|
4f454ab438 | ||
|
|
1ecf416113 | ||
|
|
94670a03ff | ||
|
|
e92f165677 | ||
|
|
4a03137a25 | ||
|
|
7e6e1fb6de | ||
|
|
f477797823 | ||
|
|
125b6740a6 | ||
|
|
1618a11955 | ||
|
|
966d6e2383 | ||
|
|
2f33a135fc | ||
|
|
207ea492d5 | ||
|
|
250d5432a0 | ||
|
|
9768758ecc | ||
|
|
20852dbd12 | ||
|
|
2bc632474d | ||
|
|
78fd754d91 | ||
|
|
bfa0045f1d | ||
|
|
97e2d58750 | ||
|
|
ff668931ba | ||
|
|
1c0149afc9 | ||
|
|
12ee3ef497 | ||
|
|
ae2e38acac | ||
|
|
f25050bce8 | ||
|
|
830d500a68 | ||
|
|
960e5d9d29 | ||
|
|
75b9f27761 | ||
|
|
67af210f07 | ||
|
|
06cdcac4df | ||
|
|
10dc1d10ed | ||
|
|
43c65bf95b | ||
|
|
cb4ee2dcca | ||
|
|
bc64a96cc0 | ||
|
|
23dab16afc | ||
|
|
8755106fd2 | ||
|
|
b2c6c95dbd | ||
|
|
20d5fcd54d | ||
|
|
0d09233b28 | ||
|
|
1f2700de38 | ||
|
|
d7ebdfbf5a | ||
|
|
14b70a78ab | ||
|
|
dd41af8b8e | ||
|
|
5b19d61069 | ||
|
|
be3e028f5c | ||
|
|
d231436eb0 | ||
|
|
4c6276d3f6 | ||
|
|
583c00d2b7 | ||
|
|
060ded3915 | ||
|
|
8482a8746f | ||
|
|
dc12c0e770 | ||
|
|
6338e89507 | ||
|
|
0f97d29f6a | ||
|
|
686f746070 | ||
|
|
5363719643 | ||
|
|
607785dcd4 | ||
|
|
c14d39c456 | ||
|
|
2c9220090a | ||
|
|
b17ef8b6ff | ||
|
|
6ac96747cf | ||
|
|
92c8a13f96 | ||
|
|
6d07c335de | ||
|
|
eba1679761 | ||
|
|
05b05be0bd | ||
|
|
287861f5d7 | ||
|
|
4102c4a0ae | ||
|
|
d8fa0e33f1 | ||
|
|
97bc638f5f | ||
|
|
064c0ae425 | ||
|
|
ae7aa52177 | ||
|
|
6edda72d61 | ||
|
|
2f58f32bdd | ||
|
|
0b821db046 | ||
|
|
36472998ee | ||
|
|
c2e7325876 | ||
|
|
28a4a3849c | ||
|
|
6e9c934912 | ||
|
|
675ef0e629 | ||
|
|
484914b2dc | ||
|
|
ee85ef50f4 | ||
|
|
dcee5542c5 | ||
|
|
9b3ce4d849 | ||
|
|
5ab7e586f3 | ||
|
|
9f5d4ed52c | ||
|
|
c3ca734005 | ||
|
|
a158a488f2 | ||
|
|
6048cb917e | ||
|
|
81aac0d431 | ||
|
|
dfb50fbddc | ||
|
|
1f03e0a84b | ||
|
|
77e393ae48 | ||
|
|
0d8820bcab |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -25,3 +25,4 @@
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
/.idea/deviceManager.xml
|
||||
/.kotlin/
|
||||
|
||||
1
.idea/.gitignore
generated
vendored
1
.idea/.gitignore
generated
vendored
@@ -2,3 +2,4 @@
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
/migrations.xml
|
||||
/runConfigurations.xml
|
||||
|
||||
@@ -15,9 +15,9 @@ android {
|
||||
defaultConfig {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
versionCode = 650
|
||||
versionName = '7.2.1'
|
||||
targetSdk = 35
|
||||
versionCode = 660
|
||||
versionName = '7.4.3'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
@@ -82,23 +82,23 @@ afterEvaluate {
|
||||
}
|
||||
dependencies {
|
||||
//noinspection GradleDependency
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:7ed8c9f787') {
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:98cbee11b9') {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.10'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0-RC.2'
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
implementation 'androidx.activity:activity-ktx:1.9.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.8.0'
|
||||
implementation 'androidx.transition:transition-ktx:1.5.0'
|
||||
implementation 'androidx.collection:collection-ktx:1.4.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.2'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.2'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.2'
|
||||
implementation 'androidx.activity:activity-ktx:1.9.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.8.2'
|
||||
implementation 'androidx.transition:transition-ktx:1.5.1'
|
||||
implementation 'androidx.collection:collection-ktx:1.4.3'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.4'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.4'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||
@@ -106,10 +106,10 @@ dependencies {
|
||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.2'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.4'
|
||||
implementation 'androidx.webkit:webkit:1.11.0'
|
||||
|
||||
implementation 'androidx.work:work-runtime:2.9.0'
|
||||
implementation 'androidx.work:work-runtime:2.9.1'
|
||||
//noinspection GradleDependency
|
||||
implementation('com.google.guava:guava:32.0.1-android') {
|
||||
exclude group: 'com.google.guava', module: 'failureaccess'
|
||||
@@ -134,9 +134,9 @@ dependencies {
|
||||
implementation 'androidx.hilt:hilt-work: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-svg:2.6.0'
|
||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:8cafac256e'
|
||||
implementation 'io.coil-kt:coil-base:2.7.0'
|
||||
implementation 'io.coil-kt:coil-svg:2.7.0'
|
||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:882bc0620c'
|
||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||
implementation 'io.noties.markwon:core:4.6.2'
|
||||
|
||||
@@ -154,10 +154,10 @@ dependencies {
|
||||
testImplementation 'org.json:json:20240303'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
|
||||
|
||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
|
||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
|
||||
androidTestImplementation 'androidx.test:runner:1.6.1'
|
||||
androidTestImplementation 'androidx.test:rules:1.6.1'
|
||||
androidTestImplementation 'androidx.test:core-ktx:1.6.1'
|
||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
|
||||
|
||||
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<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_DELETE_PACKAGES"/>
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
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.parsers.model.Manga
|
||||
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.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
@@ -57,7 +58,7 @@ class AlternativesUseCase @Inject constructor(
|
||||
}
|
||||
|
||||
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.sortByDescending { 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 {
|
||||
var res = 0
|
||||
if (locale == ref.locale) res += 2
|
||||
if (contentType == ref.contentType) res++
|
||||
if (this is MangaParserSource && ref is MangaParserSource) {
|
||||
if (locale == ref.locale) res += 2
|
||||
if (contentType == ref.contentType) res++
|
||||
}
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,178 +18,178 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||
import javax.inject.Inject
|
||||
|
||||
class MigrateUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
private val database: MangaDatabase,
|
||||
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
||||
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
|
||||
@Inject
|
||||
constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
private val database: MangaDatabase,
|
||||
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
||||
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
oldManga: Manga,
|
||||
newManga: Manga,
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
oldManga: Manga,
|
||||
newManga: Manga,
|
||||
) {
|
||||
val oldDetails =
|
||||
if (oldManga.chapters.isNullOrEmpty()) {
|
||||
runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||
}.getOrDefault(oldManga)
|
||||
} else {
|
||||
oldManga
|
||||
}
|
||||
val newDetails =
|
||||
if (newManga.chapters.isNullOrEmpty()) {
|
||||
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
||||
} else {
|
||||
newManga
|
||||
}
|
||||
mangaDataRepository.storeManga(newDetails)
|
||||
database.withTransaction {
|
||||
// replace favorites
|
||||
val favoritesDao = database.getFavouritesDao()
|
||||
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
|
||||
if (oldFavourites.isNotEmpty()) {
|
||||
favoritesDao.delete(oldManga.id)
|
||||
for (f in oldFavourites) {
|
||||
val e =
|
||||
f.copy(
|
||||
mangaId = newManga.id,
|
||||
)
|
||||
favoritesDao.upsert(e)
|
||||
}
|
||||
}
|
||||
// replace history
|
||||
val historyDao = database.getHistoryDao()
|
||||
val oldHistory = historyDao.find(oldDetails.id)
|
||||
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,
|
||||
val oldDetails =
|
||||
if (oldManga.chapters.isNullOrEmpty()) {
|
||||
runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||
}.getOrDefault(oldManga)
|
||||
} else {
|
||||
oldManga
|
||||
}
|
||||
val newDetails =
|
||||
if (newManga.chapters.isNullOrEmpty()) {
|
||||
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
||||
} else {
|
||||
newManga
|
||||
}
|
||||
mangaDataRepository.storeManga(newDetails)
|
||||
database.withTransaction {
|
||||
// replace favorites
|
||||
val favoritesDao = database.getFavouritesDao()
|
||||
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
|
||||
if (oldFavourites.isNotEmpty()) {
|
||||
favoritesDao.delete(oldManga.id)
|
||||
for (f in oldFavourites) {
|
||||
val e =
|
||||
f.copy(
|
||||
mangaId = newManga.id,
|
||||
)
|
||||
tracksDao.delete(oldDetails.id)
|
||||
tracksDao.upsert(newTrack)
|
||||
favoritesDao.upsert(e)
|
||||
}
|
||||
// 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(
|
||||
}
|
||||
// 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,
|
||||
rating = prevInfo.rating,
|
||||
status =
|
||||
prevInfo.status ?: when {
|
||||
newHistory == null -> ScrobblingStatus.PLANNED
|
||||
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
|
||||
else -> ScrobblingStatus.READING
|
||||
},
|
||||
comment = prevInfo.comment,
|
||||
lastChapterId = lastChapter?.id ?: 0L,
|
||||
newChapters = 0,
|
||||
lastCheckTime = System.currentTimeMillis(),
|
||||
lastChapterDate = lastChapter?.uploadDate ?: 0L,
|
||||
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
|
||||
lastError = null,
|
||||
)
|
||||
if (newHistory != null) {
|
||||
scrobbler.scrobble(
|
||||
manga = newDetails,
|
||||
chapterId = newHistory.chapterId,
|
||||
)
|
||||
}
|
||||
}
|
||||
tracksDao.delete(oldDetails.id)
|
||||
tracksDao.upsert(newTrack)
|
||||
}
|
||||
progressUpdateUseCase(newManga)
|
||||
}
|
||||
|
||||
private fun makeNewHistory(
|
||||
oldManga: Manga,
|
||||
newManga: Manga,
|
||||
history: HistoryEntity,
|
||||
): HistoryEntity {
|
||||
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
||||
val branch = newManga.getPreferredBranch(null)
|
||||
val chapters = checkNotNull(newManga.getChapters(branch))
|
||||
val currentChapter =
|
||||
if (history.percent in 0f..1f) {
|
||||
chapters[(chapters.lastIndex * history.percent).toInt()]
|
||||
} else {
|
||||
chapters.first()
|
||||
}
|
||||
return HistoryEntity(
|
||||
mangaId = newManga.id,
|
||||
createdAt = history.createdAt,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
chapterId = currentChapter.id,
|
||||
page = history.page,
|
||||
scroll = history.scroll,
|
||||
percent = history.percent,
|
||||
deletedAt = 0,
|
||||
chaptersCount = chapters.size,
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
|
||||
val oldChapters = checkNotNull(oldManga.getChapters(branch))
|
||||
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
|
||||
if (index < 0) {
|
||||
index =
|
||||
if (history.percent in 0f..1f) {
|
||||
(oldChapters.lastIndex * history.percent).toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
|
||||
val newBranch =
|
||||
if (newChapters.containsKey(branch)) {
|
||||
branch
|
||||
} else {
|
||||
newManga.getPreferredBranch(null)
|
||||
if (newHistory != null) {
|
||||
scrobbler.scrobble(
|
||||
manga = newDetails,
|
||||
chapterId = newHistory.chapterId,
|
||||
)
|
||||
}
|
||||
val newChapterId =
|
||||
checkNotNull(newChapters[newBranch])
|
||||
.let {
|
||||
val oldChapter = oldChapters[index]
|
||||
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
|
||||
}.id
|
||||
}
|
||||
}
|
||||
progressUpdateUseCase(newManga)
|
||||
}
|
||||
|
||||
private fun makeNewHistory(
|
||||
oldManga: Manga,
|
||||
newManga: Manga,
|
||||
history: HistoryEntity,
|
||||
): HistoryEntity {
|
||||
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
||||
val branch = newManga.getPreferredBranch(null)
|
||||
val chapters = checkNotNull(newManga.getChapters(branch))
|
||||
val currentChapter =
|
||||
if (history.percent in 0f..1f) {
|
||||
chapters[(chapters.lastIndex * history.percent).toInt()]
|
||||
} else {
|
||||
chapters.first()
|
||||
}
|
||||
return HistoryEntity(
|
||||
mangaId = newManga.id,
|
||||
createdAt = history.createdAt,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
chapterId = newChapterId,
|
||||
chapterId = currentChapter.id,
|
||||
page = history.page,
|
||||
scroll = history.scroll,
|
||||
percent = PROGRESS_NONE,
|
||||
percent = history.percent,
|
||||
deletedAt = 0,
|
||||
chaptersCount = checkNotNull(newChapters[newBranch]).size,
|
||||
chaptersCount = chapters.count { it.branch == currentChapter.branch },
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<MangaChapter>.findByNumber(
|
||||
volume: Int,
|
||||
number: Float,
|
||||
): MangaChapter? =
|
||||
if (number <= 0f) {
|
||||
null
|
||||
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 {
|
||||
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 com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
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.ui.image.ChipIconTarget
|
||||
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 ->
|
||||
chip.text = item.manga.source.title
|
||||
chip.text = item.manga.source.getTitle(chip.context)
|
||||
ImageRequest.Builder(context)
|
||||
.data(item.manga.source.faviconUri())
|
||||
.lifecycle(lifecycleOwner)
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
@@ -95,9 +96,9 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||
getString(
|
||||
R.string.migrate_confirmation,
|
||||
viewModel.manga.title,
|
||||
viewModel.manga.source.title,
|
||||
viewModel.manga.source.getTitle(this),
|
||||
target.title,
|
||||
target.source.title,
|
||||
target.source.getTitle(this),
|
||||
),
|
||||
)
|
||||
.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.parser.MangaIntent
|
||||
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.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
||||
import org.koitharu.kotatsu.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.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||
@@ -34,7 +36,8 @@ class AlternativesViewModel @Inject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val alternativesUseCase: AlternativesUseCase,
|
||||
private val migrateUseCase: MigrateUseCase,
|
||||
private val extraProvider: ListExtraProvider,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
|
||||
@@ -53,7 +56,7 @@ class AlternativesViewModel @Inject constructor(
|
||||
.map {
|
||||
MangaAlternativeModel(
|
||||
manga = it,
|
||||
progress = extraProvider.getProgress(it.id),
|
||||
progress = getProgress(it.id),
|
||||
referenceChapters = refCount,
|
||||
)
|
||||
}.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> {
|
||||
return list.map {
|
||||
MangaAlternativeModel(
|
||||
manga = it,
|
||||
progress = extraProvider.getProgress(it.id),
|
||||
referenceChapters = refCount,
|
||||
)
|
||||
}
|
||||
private suspend fun getProgress(mangaId: Long): ReadingProgress? {
|
||||
return historyRepository.getProgress(mangaId, settings.progressIndicatorMode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
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.parsers.model.Manga
|
||||
|
||||
data class MangaAlternativeModel(
|
||||
val manga: Manga,
|
||||
val progress: Float,
|
||||
val progress: ReadingProgress?,
|
||||
private val referenceChapters: Int,
|
||||
) : ListModel {
|
||||
|
||||
|
||||
@@ -37,6 +37,6 @@ fun bookmarkLargeAD(
|
||||
source(item.manga.source)
|
||||
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.updatePadding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import okhttp3.internal.userAgent
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
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.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
@@ -42,10 +43,9 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||
}
|
||||
val userAgent = intent?.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)?.let { source ->
|
||||
val repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository
|
||||
repository?.headers?.get(CommonHeaders.USER_AGENT)
|
||||
}
|
||||
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
|
||||
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
|
||||
repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
||||
viewBinding.webView.configureForParser(userAgent)
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||
@@ -147,7 +147,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
return Intent(context, BrowserActivity::class.java)
|
||||
.setData(Uri.parse(url))
|
||||
.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 org.koitharu.kotatsu.R
|
||||
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.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
class CaptchaNotifier(
|
||||
@@ -46,7 +47,7 @@ class CaptchaNotifier(
|
||||
.setGroup(GROUP_CAPTCHA)
|
||||
.setAutoCancel(true)
|
||||
.setVisibility(
|
||||
if (exception.source?.contentType == ContentType.HENTAI) {
|
||||
if (exception.source?.isNsfw() == true) {
|
||||
NotificationCompat.VISIBILITY_SECRET
|
||||
} else {
|
||||
NotificationCompat.VISIBILITY_PUBLIC
|
||||
@@ -55,7 +56,7 @@ class CaptchaNotifier(
|
||||
.setContentText(
|
||||
context.getString(
|
||||
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))
|
||||
|
||||
@@ -55,7 +55,11 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
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)
|
||||
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
|
||||
viewBinding.webView.webViewClient = cfClient
|
||||
@@ -63,12 +67,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
onBackPressedDispatcher.addCallback(it)
|
||||
}
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||
if (savedInstanceState != null) {
|
||||
return
|
||||
}
|
||||
if (url.isEmpty()) {
|
||||
finishAfterTransition()
|
||||
} else {
|
||||
if (savedInstanceState == null) {
|
||||
onTitleChanged(getString(R.string.loading_), url)
|
||||
viewBinding.webView.loadUrl(url)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.core
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.provider.SearchRecentSuggestions
|
||||
import android.text.Html
|
||||
@@ -110,6 +111,8 @@ interface AppModule {
|
||||
.decoderDispatcher(Dispatchers.IO)
|
||||
.transformationDispatcher(Dispatchers.Default)
|
||||
.diskCache(diskCacheFactory)
|
||||
.respectCacheHeaders(false)
|
||||
.networkObserverEnabled(false)
|
||||
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
||||
.allowRgb565(context.isLowRamDevice())
|
||||
.eventListener(CaptchaNotifier(context))
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
||||
|
||||
class JsonDeserializer(private val json: JSONObject) {
|
||||
@@ -85,6 +86,8 @@ class JsonDeserializer(private val json: JSONObject) {
|
||||
isEnabled = json.getBoolean("enabled"),
|
||||
sortKey = json.getInt("sort_key"),
|
||||
addedIn = json.getIntOrDefault("added_in", 0),
|
||||
lastUsedAt = json.getLongOrDefault("used_at", 0L),
|
||||
isPinned = json.getBooleanOrDefault("pinned", false),
|
||||
)
|
||||
|
||||
fun toMap(): Map<String, Any?> {
|
||||
|
||||
@@ -89,6 +89,9 @@ class JsonSerializer private constructor(private val json: JSONObject) {
|
||||
put("source", e.source)
|
||||
put("enabled", e.isEnabled)
|
||||
put("sort_key", e.sortKey)
|
||||
put("added_in", e.addedIn)
|
||||
put("used_at", e.lastUsedAt)
|
||||
put("pinned", e.isPinned)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration18To19
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration19To20
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration21To22
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
||||
@@ -59,7 +60,7 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TracksDao
|
||||
|
||||
const val DATABASE_VERSION = 21
|
||||
const val DATABASE_VERSION = 22
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
@@ -120,6 +121,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||
Migration18To19(),
|
||||
Migration19To20(),
|
||||
Migration20To21(),
|
||||
Migration21To22(),
|
||||
)
|
||||
|
||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||
|
||||
@@ -18,7 +18,7 @@ import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
||||
@Dao
|
||||
abstract class MangaSourcesDao {
|
||||
|
||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
||||
@Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key")
|
||||
abstract suspend fun findAll(): List<MangaSourceEntity>
|
||||
|
||||
@Query("SELECT source FROM sources WHERE enabled = 1")
|
||||
@@ -27,7 +27,10 @@ abstract class MangaSourcesDao {
|
||||
@Query("SELECT * FROM sources WHERE added_in >= :version")
|
||||
abstract suspend fun findAllFromVersion(version: Int): List<MangaSourceEntity>
|
||||
|
||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
||||
@Query("SELECT * FROM sources ORDER BY used_at DESC LIMIT :limit")
|
||||
abstract suspend fun findLastUsed(limit: Int): List<MangaSourceEntity>
|
||||
|
||||
@Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key")
|
||||
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
||||
|
||||
@Query("SELECT enabled FROM sources WHERE source = :source")
|
||||
@@ -42,6 +45,12 @@ abstract class MangaSourcesDao {
|
||||
@Query("UPDATE sources SET sort_key = :sortKey WHERE source = :source")
|
||||
abstract suspend fun setSortKey(source: String, sortKey: Int)
|
||||
|
||||
@Query("UPDATE sources SET used_at = :value WHERE source = :source")
|
||||
abstract suspend fun setLastUsed(source: String, value: Long)
|
||||
|
||||
@Query("UPDATE sources SET pinned = :isPinned WHERE source = :source")
|
||||
abstract suspend fun setPinned(source: String, isPinned: Boolean)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
@Transaction
|
||||
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
|
||||
@@ -49,11 +58,14 @@ abstract class MangaSourcesDao {
|
||||
@Upsert
|
||||
abstract suspend fun upsert(entry: MangaSourceEntity)
|
||||
|
||||
@Query("SELECT * FROM sources WHERE pinned = 1")
|
||||
abstract suspend fun findAllPinned(): List<MangaSourceEntity>
|
||||
|
||||
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
|
||||
val orderBy = getOrderBy(order)
|
||||
|
||||
@Language("RoomSql")
|
||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
|
||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
|
||||
return observeImpl(query)
|
||||
}
|
||||
|
||||
@@ -61,7 +73,7 @@ abstract class MangaSourcesDao {
|
||||
val orderBy = getOrderBy(order)
|
||||
|
||||
@Language("RoomSql")
|
||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
|
||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
|
||||
return findAllImpl(query)
|
||||
}
|
||||
|
||||
@@ -73,6 +85,8 @@ abstract class MangaSourcesDao {
|
||||
isEnabled = isEnabled,
|
||||
sortKey = getMaxSortKey() + 1,
|
||||
addedIn = BuildConfig.VERSION_CODE,
|
||||
lastUsedAt = 0,
|
||||
isPinned = false,
|
||||
)
|
||||
upsert(entity)
|
||||
}
|
||||
@@ -91,5 +105,6 @@ abstract class MangaSourcesDao {
|
||||
SourcesSortOrder.ALPHABETIC -> "source ASC"
|
||||
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
|
||||
SourcesSortOrder.MANUAL -> "sort_key ASC"
|
||||
SourcesSortOrder.LAST_USED -> "used_at DESC"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,4 +15,6 @@ data class MangaSourceEntity(
|
||||
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
|
||||
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
|
||||
@ColumnInfo(name = "added_in") val addedIn: Int,
|
||||
@ColumnInfo(name = "used_at") val lastUsedAt: Long,
|
||||
@ColumnInfo(name = "pinned") val isPinned: Boolean,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import android.content.Context
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.room.migration.Migration
|
||||
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) {
|
||||
|
||||
@@ -15,11 +15,8 @@ class Migration16To17(context: Context) : Migration(16, 17) {
|
||||
db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
|
||||
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
|
||||
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
|
||||
val sources = MangaSource.entries
|
||||
val sources = MangaParserSource.entries
|
||||
for (source in sources) {
|
||||
if (source == MangaSource.LOCAL) {
|
||||
continue
|
||||
}
|
||||
val name = source.name
|
||||
val isHidden = name in hiddenSources
|
||||
var sortKey = order.indexOf(name)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration21To22 : Migration(21, 22) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE sources ADD COLUMN `used_at` INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE sources ADD COLUMN `pinned` INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
class IncompatiblePluginException(
|
||||
val name: String?,
|
||||
cause: Throwable?,
|
||||
) : RuntimeException(cause)
|
||||
@@ -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.Manga
|
||||
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.util.formatSimple
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
@@ -109,7 +108,7 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
|
||||
}
|
||||
|
||||
val Manga.isLocal: Boolean
|
||||
get() = source == MangaSource.LOCAL
|
||||
get() = source == LocalMangaSource
|
||||
|
||||
val Manga.appUrl: Uri
|
||||
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.SuperscriptSpan
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
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.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||
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.util.splitTwoParts
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
fun MangaSource(name: String): MangaSource {
|
||||
MangaSource.entries.forEach {
|
||||
if (it.name == name) return it
|
||||
}
|
||||
return MangaSource.UNKNOWN
|
||||
data object LocalMangaSource : MangaSource {
|
||||
override val name = "LOCAL"
|
||||
}
|
||||
|
||||
fun MangaSource.isNsfw() = contentType == ContentType.HENTAI
|
||||
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
|
||||
}
|
||||
return UnknownMangaSource
|
||||
}
|
||||
|
||||
fun MangaSource.isNsfw(): Boolean = when (this) {
|
||||
is MangaSourceInfo -> mangaSource.isNsfw()
|
||||
is MangaParserSource -> contentType == ContentType.HENTAI
|
||||
else -> false
|
||||
}
|
||||
|
||||
@get:StringRes
|
||||
val ContentType.titleResId
|
||||
@@ -35,23 +58,28 @@ val ContentType.titleResId
|
||||
ContentType.OTHER -> R.string.content_type_other
|
||||
}
|
||||
|
||||
fun MangaSource.getSummary(context: Context): String {
|
||||
val type = context.getString(contentType.titleResId)
|
||||
val locale = locale.toLocale().getDisplayName(context)
|
||||
return context.getString(R.string.source_summary_pattern, type, locale)
|
||||
}
|
||||
|
||||
fun MangaSource.getTitle(context: Context): CharSequence = if (isNsfw()) {
|
||||
buildSpannedString {
|
||||
append(title)
|
||||
append(' ')
|
||||
appendNsfwLabel(context)
|
||||
fun MangaSource.getSummary(context: Context): String? = when (this) {
|
||||
is MangaSourceInfo -> mangaSource.getSummary(context)
|
||||
is MangaParserSource -> {
|
||||
val type = context.getString(contentType.titleResId)
|
||||
val locale = locale.toLocale().getDisplayName(context)
|
||||
context.getString(R.string.source_summary_pattern, type, locale)
|
||||
}
|
||||
} else {
|
||||
title
|
||||
|
||||
is ExternalMangaSource -> context.getString(R.string.external_source)
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
|
||||
fun MangaSource.getTitle(context: Context): String = when (this) {
|
||||
is MangaSourceInfo -> mangaSource.getTitle(context)
|
||||
is MangaParserSource -> title
|
||||
LocalMangaSource -> context.getString(R.string.local_storage)
|
||||
is ExternalMangaSource -> resolveName(context)
|
||||
else -> context.getString(R.string.unknown)
|
||||
}
|
||||
|
||||
fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
|
||||
ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)),
|
||||
RelativeSizeSpan(0.74f),
|
||||
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 kotlinx.parcelize.Parceler
|
||||
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.MangaSource
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableChapter(
|
||||
@@ -25,8 +24,8 @@ data class ParcelableChapter(
|
||||
scanlator = parcel.readString(),
|
||||
uploadDate = parcel.readLong(),
|
||||
branch = parcel.readString(),
|
||||
source = parcel.readSerializableCompat() ?: MangaSource.UNKNOWN,
|
||||
)
|
||||
source = MangaSource(parcel.readString()),
|
||||
),
|
||||
)
|
||||
|
||||
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
|
||||
@@ -38,7 +37,7 @@ data class ParcelableChapter(
|
||||
parcel.writeString(scanlator)
|
||||
parcel.writeLong(uploadDate)
|
||||
parcel.writeString(branch)
|
||||
parcel.writeSerializable(source)
|
||||
parcel.writeString(source.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.os.Parcelable
|
||||
import androidx.core.os.ParcelCompat
|
||||
import kotlinx.parcelize.Parceler
|
||||
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.readSerializableCompat
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
@@ -30,7 +31,7 @@ data class ParcelableManga(
|
||||
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
|
||||
parcel.writeSerializable(state)
|
||||
parcel.writeString(author)
|
||||
parcel.writeSerializable(source)
|
||||
parcel.writeString(source.name)
|
||||
}
|
||||
|
||||
override fun create(parcel: Parcel) = ParcelableManga(
|
||||
@@ -49,8 +50,8 @@ data class ParcelableManga(
|
||||
state = parcel.readSerializableCompat(),
|
||||
author = parcel.readString(),
|
||||
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.Parcelize
|
||||
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
|
||||
|
||||
object MangaPageParceler : Parceler<MangaPage> {
|
||||
@@ -13,14 +13,14 @@ object MangaPageParceler : Parceler<MangaPage> {
|
||||
id = parcel.readLong(),
|
||||
url = requireNotNull(parcel.readString()),
|
||||
preview = parcel.readString(),
|
||||
source = requireNotNull(parcel.readSerializableCompat()),
|
||||
source = MangaSource(parcel.readString()),
|
||||
)
|
||||
|
||||
override fun MangaPage.write(parcel: Parcel, flags: Int) {
|
||||
parcel.writeLong(id)
|
||||
parcel.writeString(url)
|
||||
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.Parcelize
|
||||
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
|
||||
|
||||
object MangaTagParceler : Parceler<MangaTag> {
|
||||
override fun create(parcel: Parcel) = MangaTag(
|
||||
title = requireNotNull(parcel.readString()),
|
||||
key = requireNotNull(parcel.readString()),
|
||||
source = requireNotNull(parcel.readSerializableCompat()),
|
||||
source = MangaSource(parcel.readString()),
|
||||
)
|
||||
|
||||
override fun MangaTag.write(parcel: Parcel, flags: Int) {
|
||||
parcel.writeString(title)
|
||||
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.core.parser.MangaLoaderContextImpl
|
||||
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.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mergeWith
|
||||
@@ -30,7 +30,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
||||
val request = chain.request()
|
||||
val source = request.tag(MangaSource::class.java)
|
||||
val repository = if (source != null) {
|
||||
mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository
|
||||
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
|
||||
} else {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.w("Http", "Request without source tag: ${request.url}")
|
||||
@@ -38,7 +38,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
||||
null
|
||||
}
|
||||
val headersBuilder = request.headers.newBuilder()
|
||||
repository?.headers?.let {
|
||||
repository?.getRequestHeaders()?.let {
|
||||
headersBuilder.mergeWith(it, replaceExisting = false)
|
||||
}
|
||||
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
|
||||
|
||||
@@ -13,8 +13,9 @@ import okhttp3.internal.canParseAsIpAddress
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okhttp3.internal.publicsuffix.PublicSuffixDatabase
|
||||
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.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import java.util.EnumMap
|
||||
import javax.inject.Inject
|
||||
@@ -26,8 +27,8 @@ class MirrorSwitchInterceptor @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) : Interceptor {
|
||||
|
||||
private val locks = EnumMap<MangaSource, Any>(MangaSource::class.java)
|
||||
private val blacklist = EnumMap<MangaSource, MutableSet<String>>(MangaSource::class.java)
|
||||
private val locks = EnumMap<MangaParserSource, Any>(MangaParserSource::class.java)
|
||||
private val blacklist = EnumMap<MangaParserSource, MutableSet<String>>(MangaParserSource::class.java)
|
||||
|
||||
val isEnabled: Boolean
|
||||
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) {
|
||||
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)
|
||||
repository.domain = oldMirror
|
||||
}
|
||||
|
||||
private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? {
|
||||
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()
|
||||
if (mirrors.isEmpty()) {
|
||||
return null
|
||||
@@ -93,7 +94,7 @@ class MirrorSwitchInterceptor @Inject constructor(
|
||||
}
|
||||
|
||||
private fun tryMirrors(
|
||||
repository: RemoteMangaRepository,
|
||||
repository: ParserMangaRepository,
|
||||
mirrors: List<String>,
|
||||
chain: Interceptor.Chain,
|
||||
request: Request,
|
||||
@@ -145,15 +146,15 @@ class MirrorSwitchInterceptor @Inject constructor(
|
||||
return source().readByteArray().toResponseBody(contentType())
|
||||
}
|
||||
|
||||
private fun obtainLock(source: MangaSource): Any = locks.getOrPut(source) {
|
||||
private fun obtainLock(source: MangaParserSource): Any = locks.getOrPut(source) {
|
||||
Any()
|
||||
}
|
||||
|
||||
private fun isBlacklisted(source: MangaSource, domain: String): Boolean {
|
||||
private fun isBlacklisted(source: MangaParserSource, domain: String): Boolean {
|
||||
return blacklist[source]?.contains(domain) == true
|
||||
}
|
||||
|
||||
private fun addToBlacklist(source: MangaSource, domain: String) {
|
||||
private fun addToBlacklist(source: MangaParserSource, domain: String) {
|
||||
blacklist.getOrPut(source) {
|
||||
ArraySet(2)
|
||||
}.add(domain)
|
||||
|
||||
@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
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.favicon.faviconUri
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
@@ -173,9 +174,10 @@ class AppShortcutManager @Inject constructor(
|
||||
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
|
||||
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
|
||||
)
|
||||
val title = source.getTitle(context)
|
||||
ShortcutInfoCompat.Builder(context, source.name)
|
||||
.setShortLabel(source.title)
|
||||
.setLongLabel(source.title)
|
||||
.setShortLabel(title)
|
||||
.setLongLabel(title)
|
||||
.setIcon(icon)
|
||||
.setLongLived(true)
|
||||
.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.MangaListFilter
|
||||
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.SortOrder
|
||||
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
|
||||
*/
|
||||
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
|
||||
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParserSource.DUMMY) {
|
||||
|
||||
override val configKeyDomain: ConfigKey.Domain
|
||||
get() = ConfigKey.Domain("localhost")
|
||||
|
||||
@@ -1,38 +1,49 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
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.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.util.EnumSet
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* This parser is just for parser development, it should not be used in releases
|
||||
*/
|
||||
class EmptyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
|
||||
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
||||
|
||||
override val configKeyDomain: ConfigKey.Domain
|
||||
get() = ConfigKey.Domain("localhost")
|
||||
|
||||
override val availableSortOrders: Set<SortOrder>
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||
override val states: Set<MangaState>
|
||||
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 getDetails(manga: Manga): Manga = stub(manga)
|
||||
|
||||
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 getRelatedManga(seed: Manga): List<Manga> = stub(seed)
|
||||
override suspend fun getTags(): Set<MangaTag> = stub(null)
|
||||
|
||||
override suspend fun getLocales(): Set<Locale> = stub(null)
|
||||
|
||||
override suspend fun getRelated(seed: Manga): List<Manga> = stub(seed)
|
||||
|
||||
private fun stub(manga: Manga?): Nothing {
|
||||
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.toManga
|
||||
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.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
||||
@@ -101,7 +102,7 @@ class MangaDataRepository @Inject constructor(
|
||||
|
||||
suspend fun cleanupLocalManga() {
|
||||
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 }
|
||||
if (broken.isNotEmpty()) {
|
||||
dao.delete(broken.map { it.manga })
|
||||
|
||||
@@ -4,10 +4,11 @@ import android.net.Uri
|
||||
import coil.request.CachePolicy
|
||||
import dagger.Reusable
|
||||
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.explore.data.MangaSourcesRepository
|
||||
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.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
@@ -36,7 +37,7 @@ class MangaLinkResolver @Inject constructor(
|
||||
require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" }
|
||||
val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" }
|
||||
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)
|
||||
return repo.findExact(
|
||||
url = uri.getQueryParameter("url"),
|
||||
@@ -51,7 +52,7 @@ class MangaLinkResolver @Inject constructor(
|
||||
val host = uri.host ?: return null
|
||||
val repo = sourcesRepository.allMangaSources.asSequence()
|
||||
.map { source ->
|
||||
repositoryFactory.create(source) as RemoteMangaRepository
|
||||
repositoryFactory.create(source) as ParserMangaRepository
|
||||
}.find { repo ->
|
||||
host in repo.domains
|
||||
} ?: return null
|
||||
@@ -85,7 +86,7 @@ class MangaLinkResolver @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
|
||||
return if (this is RemoteMangaRepository) {
|
||||
return if (this is ParserMangaRepository) {
|
||||
getDetails(manga, CachePolicy.READ_ONLY)
|
||||
} else {
|
||||
getDetails(manga)
|
||||
@@ -108,7 +109,7 @@ class MangaLinkResolver @Inject constructor(
|
||||
url = url,
|
||||
publicUrl = "",
|
||||
rating = 0.0f,
|
||||
isNsfw = source.contentType == ContentType.HENTAI,
|
||||
isNsfw = source.isNsfw(),
|
||||
coverUrl = "",
|
||||
tags = emptySet(),
|
||||
state = null,
|
||||
|
||||
@@ -2,12 +2,11 @@ package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
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) {
|
||||
MangaSource.UNKNOWN -> EmptyParser(loaderContext)
|
||||
MangaSource.DUMMY -> DummyParser(loaderContext)
|
||||
MangaParserSource.DUMMY -> DummyParser(loaderContext)
|
||||
else -> loaderContext.newParserInstance(source)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import android.content.Context
|
||||
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.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.parser.external.ExternalMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
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.MangaListFilter
|
||||
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.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.EnumMap
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -53,32 +61,60 @@ interface MangaRepository {
|
||||
|
||||
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
|
||||
class Factory @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
private val loaderContext: MangaLoaderContext,
|
||||
private val contentCache: MemoryContentCache,
|
||||
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
|
||||
) {
|
||||
|
||||
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
|
||||
private val cache = ArrayMap<MangaSource, WeakReference<MangaRepository>>()
|
||||
|
||||
@AnyThread
|
||||
fun create(source: MangaSource): MangaRepository {
|
||||
if (source == MangaSource.LOCAL) {
|
||||
return localMangaRepository
|
||||
when (source) {
|
||||
is MangaSourceInfo -> return create(source.mangaSource)
|
||||
LocalMangaSource -> return localMangaRepository
|
||||
UnknownMangaSource -> return EmptyMangaRepository(source)
|
||||
}
|
||||
cache[source]?.get()?.let { return it }
|
||||
return synchronized(cache) {
|
||||
cache[source]?.get()?.let { return it }
|
||||
val repository = RemoteMangaRepository(
|
||||
parser = MangaParser(source, loaderContext),
|
||||
cache = contentCache,
|
||||
mirrorSwitchInterceptor = mirrorSwitchInterceptor,
|
||||
)
|
||||
cache[source] = WeakReference(repository)
|
||||
repository
|
||||
val repository = createRepository(source)
|
||||
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,
|
||||
)
|
||||
} else {
|
||||
EmptyMangaRepository(source)
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
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.Interceptor
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
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.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.MangaParserAuthProvider
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
@@ -28,7 +14,7 @@ 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.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
@@ -36,17 +22,13 @@ import org.koitharu.kotatsu.parsers.util.domain
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.util.Locale
|
||||
|
||||
class RemoteMangaRepository(
|
||||
class ParserMangaRepository(
|
||||
private val parser: MangaParser,
|
||||
private val cache: MemoryContentCache,
|
||||
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
|
||||
) : MangaRepository, Interceptor {
|
||||
cache: MemoryContentCache,
|
||||
) : CachingMangaRepository(cache), Interceptor {
|
||||
|
||||
private val detailsMutex = MultiMutex<Long>()
|
||||
private val relatedMangaMutex = MultiMutex<Long>()
|
||||
private val pagesMutex = MultiMutex<Long>()
|
||||
|
||||
override val source: MangaSource
|
||||
override val source: MangaParserSource
|
||||
get() = parser.source
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
@@ -82,9 +64,6 @@ class RemoteMangaRepository(
|
||||
val domains: Array<out String>
|
||||
get() = parser.configKeyDomain.presetValues
|
||||
|
||||
val headers: Headers
|
||||
get() = parser.headers
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
return if (parser is Interceptor) {
|
||||
parser.intercept(chain)
|
||||
@@ -99,18 +78,11 @@ class RemoteMangaRepository(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = pagesMutex.withLock(chapter.id) {
|
||||
cache.getPages(source, chapter.url)?.let { return it }
|
||||
val pages = asyncSafe {
|
||||
mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
parser.getPages(chapter).distinctById()
|
||||
}
|
||||
}
|
||||
cache.putPages(source, chapter.url, pages)
|
||||
pages
|
||||
}.await()
|
||||
override suspend fun getPagesImpl(
|
||||
chapter: MangaChapter
|
||||
): List<MangaPage> = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
parser.getPages(chapter)
|
||||
}
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
parser.getPageUrl(page)
|
||||
@@ -128,41 +100,16 @@ class RemoteMangaRepository(
|
||||
parser.getFavicons()
|
||||
}
|
||||
|
||||
override suspend fun getRelated(seed: Manga): List<Manga> = relatedMangaMutex.withLock(seed.id) {
|
||||
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()
|
||||
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = parser.getRelatedManga(seed)
|
||||
|
||||
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? {
|
||||
return cache.getDetails(source, manga.url)
|
||||
}
|
||||
|
||||
suspend fun find(manga: Manga): Manga? {
|
||||
val list = getList(0, MangaListFilter.Search(manga.title))
|
||||
return list.find { x -> x.id == manga.id }
|
||||
override suspend fun getDetailsImpl(manga: Manga): Manga = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
parser.getDetails(manga)
|
||||
}
|
||||
|
||||
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
|
||||
|
||||
fun getRequestHeaders() = parser.getRequestHeaders()
|
||||
|
||||
fun getConfigKeys(): List<ConfigKey<*>> = ArrayList<ConfigKey<*>>().also {
|
||||
parser.onCreateConfig(it)
|
||||
}
|
||||
@@ -175,40 +122,8 @@ class RemoteMangaRepository(
|
||||
return getConfig().isSlowdownEnabled
|
||||
}
|
||||
|
||||
fun invalidateCache() {
|
||||
cache.clear(source)
|
||||
}
|
||||
|
||||
fun getConfig() = parser.config as SourceSettings
|
||||
|
||||
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
|
||||
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
|
||||
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 {
|
||||
if (!isEnabled) {
|
||||
return block()
|
||||
@@ -220,14 +135,14 @@ class RemoteMangaRepository(
|
||||
if (result.isValidResult()) {
|
||||
return result.getOrThrow()
|
||||
}
|
||||
return if (trySwitchMirror(this@RemoteMangaRepository)) {
|
||||
return if (trySwitchMirror(this@ParserMangaRepository)) {
|
||||
val newResult = runCatchingCancellable {
|
||||
block()
|
||||
}
|
||||
if (newResult.isValidResult()) {
|
||||
return newResult.getOrThrow()
|
||||
} else {
|
||||
rollback(this@RemoteMangaRepository, initialMirror)
|
||||
rollback(this@ParserMangaRepository, initialMirror)
|
||||
return result.getOrThrow()
|
||||
}
|
||||
} else {
|
||||
80
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt
vendored
Normal file
80
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
package org.koitharu.kotatsu.core.parser.external
|
||||
|
||||
import android.content.ContentResolver
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
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 java.util.EnumSet
|
||||
import java.util.Locale
|
||||
|
||||
class ExternalMangaRepository(
|
||||
private val contentResolver: ContentResolver,
|
||||
override val source: ExternalMangaSource,
|
||||
cache: MemoryContentCache,
|
||||
) : CachingMangaRepository(cache) {
|
||||
|
||||
private val contentSource = ExternalPluginContentSource(contentResolver, source)
|
||||
|
||||
private val capabilities by lazy {
|
||||
runCatching {
|
||||
contentSource.getCapabilities()
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
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.IO) {
|
||||
contentSource.getList(offset, filter)
|
||||
}
|
||||
|
||||
override suspend fun getDetailsImpl(manga: Manga): Manga = runInterruptible(Dispatchers.IO) {
|
||||
contentSource.getDetails(manga)
|
||||
}
|
||||
|
||||
override suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
|
||||
contentSource.getPages(chapter)
|
||||
}
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage): String = page.url // TODO
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> = runInterruptible(Dispatchers.IO) {
|
||||
contentSource.getTags()
|
||||
}
|
||||
|
||||
override suspend fun getLocales(): Set<Locale> = emptySet() // TODO
|
||||
|
||||
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO
|
||||
}
|
||||
30
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaSource.kt
vendored
Normal file
30
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaSource.kt
vendored
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
291
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt
vendored
Normal file
291
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt
vendored
Normal file
@@ -0,0 +1,291 @@
|
||||
package org.koitharu.kotatsu.core.parser.external
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.database.Cursor
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.core.net.toUri
|
||||
import org.jetbrains.annotations.Blocking
|
||||
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||
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 ExternalPluginContentSource(
|
||||
private val contentResolver: ContentResolver,
|
||||
private val source: ExternalMangaSource,
|
||||
) {
|
||||
|
||||
@Blocking
|
||||
@WorkerThread
|
||||
fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = runCatchingCompatibility {
|
||||
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)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
val result = ArrayList<Manga>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += cursor.getManga()
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@WorkerThread
|
||||
fun getDetails(manga: Manga) = runCatchingCompatibility {
|
||||
val chapters = 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,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@WorkerThread
|
||||
fun getPages(chapter: MangaChapter): List<MangaPage> = runCatchingCompatibility {
|
||||
val uri = "content://${source.authority}/chapters".toUri()
|
||||
.buildUpon()
|
||||
.appendPath(chapter.url)
|
||||
.build()
|
||||
contentResolver.query(uri, null, null, null, null)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
val result = ArrayList<MangaPage>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += MangaPage(
|
||||
id = cursor.getLong(COLUMN_ID),
|
||||
url = cursor.getString(COLUMN_URL),
|
||||
preview = cursor.getStringOrNull(COLUMN_PREVIEW),
|
||||
source = source,
|
||||
)
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@WorkerThread
|
||||
fun getTags(): Set<MangaTag> = runCatchingCompatibility {
|
||||
val uri = "content://${source.authority}/tags".toUri()
|
||||
contentResolver.query(uri, null, null, null, null)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
val result = ArraySet<MangaTag>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += MangaTag(
|
||||
key = cursor.getString(COLUMN_KEY),
|
||||
title = cursor.getString(COLUMN_TITLE),
|
||||
source = source,
|
||||
)
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fun getCapabilities(): MangaSourceCapabilities? {
|
||||
val uri = "content://${source.authority}/capabilities".toUri()
|
||||
return contentResolver.query(uri, null, null, null, null)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
MangaSourceCapabilities(
|
||||
availableSortOrders = cursor.getStringOrNull(COLUMN_SORT_ORDERS)
|
||||
?.split(',')
|
||||
?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) {
|
||||
SortOrder.entries.find(it)
|
||||
}.orEmpty(),
|
||||
availableStates = cursor.getStringOrNull(COLUMN_STATES)
|
||||
?.split(',')
|
||||
?.mapNotNullTo(EnumSet.noneOf(MangaState::class.java)) {
|
||||
MangaState.entries.find(it)
|
||||
}.orEmpty(),
|
||||
availableContentRating = cursor.getStringOrNull(COLUMN_CONTENT_RATING)
|
||||
?.split(',')
|
||||
?.mapNotNullTo(EnumSet.noneOf(ContentRating::class.java)) {
|
||||
ContentRating.entries.find(it)
|
||||
}.orEmpty(),
|
||||
isMultipleTagsSupported = cursor.getBooleanOrDefault(COLUMN_MULTIPLE_TAGS_SUPPORTED, true),
|
||||
isTagsExclusionSupported = cursor.getBooleanOrDefault(COLUMN_TAGS_EXCLUSION_SUPPORTED, false),
|
||||
isSearchSupported = cursor.getBooleanOrDefault(COLUMN_SEARCH_SUPPORTED, true),
|
||||
contentType = cursor.getStringOrNull(COLUMN_CONTENT_TYPE)?.let {
|
||||
ContentType.entries.find(it)
|
||||
} ?: ContentType.OTHER,
|
||||
defaultSortOrder = cursor.getStringOrNull(COLUMN_DEFAULT_SORT_ORDER)?.let {
|
||||
SortOrder.entries.find(it)
|
||||
} ?: SortOrder.ALPHABETICAL,
|
||||
sourceLocale = cursor.getStringOrNull(COLUMN_LOCALE)?.toLocale() ?: Locale.ROOT,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryDetails(url: String): Manga {
|
||||
val uri = "content://${source.authority}/manga".toUri()
|
||||
.buildUpon()
|
||||
.appendPath(url)
|
||||
.build()
|
||||
return contentResolver.query(uri, null, null, null, null)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getManga()
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryChapters(url: String): List<MangaChapter> {
|
||||
val uri = "content://${source.authority}/manga/chapters".toUri()
|
||||
.buildUpon()
|
||||
.appendPath(url)
|
||||
.build()
|
||||
return contentResolver.query(uri, null, null, null, null)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
val result = ArrayList<MangaChapter>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += MangaChapter(
|
||||
id = cursor.getLong(COLUMN_ID),
|
||||
name = cursor.getString(COLUMN_NAME),
|
||||
number = cursor.getFloatOrDefault(COLUMN_NUMBER, 0f),
|
||||
volume = cursor.getIntOrDefault(COLUMN_VOLUME, 0),
|
||||
url = cursor.getString(COLUMN_URL),
|
||||
scanlator = cursor.getStringOrNull(COLUMN_SCANLATOR),
|
||||
uploadDate = cursor.getLongOrDefault(COLUMN_UPLOAD_DATE, 0L),
|
||||
branch = cursor.getStringOrNull(COLUMN_BRANCH),
|
||||
source = source,
|
||||
)
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
private fun SafeCursor.getManga() = Manga(
|
||||
id = getLong(COLUMN_ID),
|
||||
title = getString(COLUMN_TITLE),
|
||||
altTitle = getStringOrNull(COLUMN_ALT_TITLE),
|
||||
url = getString(COLUMN_URL),
|
||||
publicUrl = getString(COLUMN_PUBLIC_URL),
|
||||
rating = getFloat(COLUMN_RATING),
|
||||
isNsfw = getBooleanOrDefault(COLUMN_IS_NSFW, false),
|
||||
coverUrl = getString(COLUMN_COVER_URL),
|
||||
tags = getStringOrNull(COLUMN_TAGS)?.split(':')?.mapNotNullToSet {
|
||||
val parts = it.splitTwoParts('=') ?: return@mapNotNullToSet null
|
||||
MangaTag(key = parts.first, title = parts.second, source = source)
|
||||
}.orEmpty(),
|
||||
state = getStringOrNull(COLUMN_STATE)?.let { MangaState.entries.find(it) },
|
||||
author = getStringOrNull(COLUMN_AUTHOR),
|
||||
largeCoverUrl = getStringOrNull(COLUMN_LARGE_COVER_URL),
|
||||
description = getStringOrNull(COLUMN_DESCRIPTION),
|
||||
chapters = emptyList(),
|
||||
source = source,
|
||||
)
|
||||
|
||||
private inline fun <R> runCatchingCompatibility(block: () -> R): R = try {
|
||||
block()
|
||||
} catch (e: NoSuchElementException) { // unknown column name
|
||||
throw IncompatiblePluginException(source.name, e)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw IncompatiblePluginException(source.name, e)
|
||||
}
|
||||
|
||||
private fun Cursor?.safe() = SafeCursor(this ?: throw IncompatiblePluginException(source.name, null))
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
|
||||
const val COLUMN_SORT_ORDERS = "sort_orders"
|
||||
const val COLUMN_STATES = "states"
|
||||
const val COLUMN_CONTENT_RATING = "content_rating"
|
||||
const val COLUMN_MULTIPLE_TAGS_SUPPORTED = "multiple_tags_supported"
|
||||
const val COLUMN_TAGS_EXCLUSION_SUPPORTED = "tags_exclusion_supported"
|
||||
const val COLUMN_SEARCH_SUPPORTED = "search_supported"
|
||||
const val COLUMN_CONTENT_TYPE = "content_type"
|
||||
const val COLUMN_DEFAULT_SORT_ORDER = "default_sort_order"
|
||||
const val COLUMN_LOCALE = "locale"
|
||||
const val COLUMN_ID = "id"
|
||||
const val COLUMN_NAME = "name"
|
||||
const val COLUMN_NUMBER = "number"
|
||||
const val COLUMN_VOLUME = "volume"
|
||||
const val COLUMN_URL = "url"
|
||||
const val COLUMN_SCANLATOR = "scanlator"
|
||||
const val COLUMN_UPLOAD_DATE = "upload_date"
|
||||
const val COLUMN_BRANCH = "branch"
|
||||
const val COLUMN_TITLE = "title"
|
||||
const val COLUMN_ALT_TITLE = "alt_title"
|
||||
const val COLUMN_PUBLIC_URL = "public_url"
|
||||
const val COLUMN_RATING = "rating"
|
||||
const val COLUMN_IS_NSFW = "is_nsfw"
|
||||
const val COLUMN_COVER_URL = "cover_url"
|
||||
const val COLUMN_TAGS = "tags"
|
||||
const val COLUMN_STATE = "state"
|
||||
const val COLUMN_AUTHOR = "author"
|
||||
const val COLUMN_LARGE_COVER_URL = "large_cover_url"
|
||||
const val COLUMN_DESCRIPTION = "description"
|
||||
const val COLUMN_PREVIEW = "preview"
|
||||
const val COLUMN_KEY = "key"
|
||||
}
|
||||
}
|
||||
73
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/SafeCursor.kt
vendored
Normal file
73
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/SafeCursor.kt
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
package org.koitharu.kotatsu.core.parser.external
|
||||
|
||||
import android.database.Cursor
|
||||
import android.database.CursorWrapper
|
||||
import org.koitharu.kotatsu.core.util.ext.getBoolean
|
||||
|
||||
class SafeCursor(cursor: Cursor) : CursorWrapper(cursor) {
|
||||
|
||||
fun getString(columnName: String): String {
|
||||
return getString(getColumnIndexOrThrow(columnName))
|
||||
}
|
||||
|
||||
fun getStringOrNull(columnName: String): String? {
|
||||
val columnIndex = getColumnIndex(columnName)
|
||||
return when {
|
||||
columnIndex < 0 -> null
|
||||
isNull(columnIndex) -> null
|
||||
else -> getString(columnIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun getBoolean(columnName: String): Boolean {
|
||||
return getBoolean(getColumnIndexOrThrow(columnName))
|
||||
}
|
||||
|
||||
fun getBooleanOrDefault(columnName: String, defaultValue: Boolean): Boolean {
|
||||
val columnIndex = getColumnIndex(columnName)
|
||||
return when {
|
||||
columnIndex < 0 -> defaultValue
|
||||
isNull(columnIndex) -> defaultValue
|
||||
else -> getBoolean(columnIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun getInt(columnName: String): Int {
|
||||
return getInt(getColumnIndexOrThrow(columnName))
|
||||
}
|
||||
|
||||
fun getIntOrDefault(columnName: String, defaultValue: Int): Int {
|
||||
val columnIndex = getColumnIndex(columnName)
|
||||
return when {
|
||||
columnIndex < 0 -> defaultValue
|
||||
isNull(columnIndex) -> defaultValue
|
||||
else -> getInt(columnIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun getLong(columnName: String): Long {
|
||||
return getLong(getColumnIndexOrThrow(columnName))
|
||||
}
|
||||
|
||||
fun getLongOrDefault(columnName: String, defaultValue: Long): Long {
|
||||
val columnIndex = getColumnIndex(columnName)
|
||||
return when {
|
||||
columnIndex < 0 -> defaultValue
|
||||
isNull(columnIndex) -> defaultValue
|
||||
else -> getLong(columnIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun getFloat(columnName: String): Float {
|
||||
return getFloat(getColumnIndexOrThrow(columnName))
|
||||
}
|
||||
|
||||
fun getFloatOrDefault(columnName: String, defaultValue: Float): Float {
|
||||
val columnIndex = getColumnIndex(columnName)
|
||||
return when {
|
||||
columnIndex < 0 -> defaultValue
|
||||
isNull(columnIndex) -> defaultValue
|
||||
else -> getFloat(columnIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
package org.koitharu.kotatsu.core.parser.favicon
|
||||
|
||||
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.os.Build
|
||||
import android.webkit.MimeTypeMap
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DataSource
|
||||
import coil.decode.ImageSource
|
||||
import coil.disk.DiskCache
|
||||
import coil.fetch.DrawableResult
|
||||
import coil.fetch.FetchResult
|
||||
import coil.fetch.Fetcher
|
||||
import coil.fetch.SourceResult
|
||||
@@ -14,7 +21,9 @@ import coil.network.HttpException
|
||||
import coil.request.Options
|
||||
import coil.size.Size
|
||||
import coil.size.pxOrElse
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
@@ -24,8 +33,10 @@ import okio.Closeable
|
||||
import okio.buffer
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
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.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.writeAllCancellable
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
@@ -46,14 +57,27 @@ class FaviconFetcher(
|
||||
) : Fetcher {
|
||||
|
||||
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
|
||||
get() = checkNotNull(diskCache.value).fileSystem
|
||||
|
||||
override suspend fun fetch(): FetchResult {
|
||||
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(
|
||||
options.size.width.pxOrElse { FALLBACK_SIZE },
|
||||
options.size.height.pxOrElse { FALLBACK_SIZE },
|
||||
@@ -100,6 +124,20 @@ class FaviconFetcher(
|
||||
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? {
|
||||
if (!options.diskCachePolicy.readEnabled) {
|
||||
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(
|
||||
context: Context,
|
||||
okHttpClientLazy: Lazy<OkHttpClient>,
|
||||
|
||||
@@ -33,7 +33,6 @@ import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
|
||||
import java.io.File
|
||||
import java.net.Proxy
|
||||
import java.util.EnumSet
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -193,8 +192,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getBoolean(KEY_FEED_HEADER, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_FEED_HEADER, value) }
|
||||
|
||||
val isReadingIndicatorsEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_READING_INDICATORS, true)
|
||||
val progressIndicatorMode: ProgressIndicatorMode
|
||||
get() = prefs.getEnumValue(KEY_PROGRESS_INDICATORS, ProgressIndicatorMode.PERCENT_READ)
|
||||
|
||||
val isHistoryExcludeNsfw: Boolean
|
||||
get() = prefs.getBoolean(KEY_HISTORY_EXCLUDE_NSFW, false)
|
||||
@@ -485,6 +484,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isAutoLocalChaptersCleanupEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_CHAPTERS_CLEAR_AUTO, false)
|
||||
|
||||
fun isPagesCropEnabled(mode: ReaderMode): Boolean {
|
||||
val rawValue = prefs.getStringSet(KEY_READER_CROP, emptySet())
|
||||
if (rawValue.isNullOrEmpty()) {
|
||||
return false
|
||||
}
|
||||
val needle = if (mode == ReaderMode.WEBTOON) READER_CROP_WEBTOON else READER_CROP_PAGED
|
||||
return needle.toString() in rawValue
|
||||
}
|
||||
|
||||
fun isTipEnabled(tip: String): Boolean {
|
||||
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
|
||||
}
|
||||
@@ -597,6 +605,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_READER_ANIMATION = "reader_animation2"
|
||||
const val KEY_READER_MODE = "reader_mode"
|
||||
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
|
||||
const val KEY_READER_CROP = "reader_crop"
|
||||
const val KEY_APP_PASSWORD = "app_password"
|
||||
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
|
||||
const val KEY_PROTECT_APP = "protect_app"
|
||||
@@ -610,7 +619,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
|
||||
const val KEY_HISTORY_GROUPING = "history_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_GRID_VIEW_CHAPTERS = "grid_view_chapters"
|
||||
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
|
||||
@@ -695,8 +704,13 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_LOGS_SHARE = "logs_share"
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
const val KEY_APP_TRANSLATION = "about_app_translation"
|
||||
const val PROXY_TEST = "proxy_test"
|
||||
|
||||
// old keys are for migration only
|
||||
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"
|
||||
|
||||
// values
|
||||
private const val READER_CROP_PAGED = 1
|
||||
private const val READER_CROP_WEBTOON = 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
enum class ProgressIndicatorMode {
|
||||
|
||||
NONE, PERCENT_READ, PERCENT_LEFT, CHAPTERS_READ, CHAPTERS_LEFT;
|
||||
}
|
||||
@@ -12,5 +12,6 @@ enum class SearchSuggestionType(
|
||||
QUERIES_SUGGEST(R.string.suggested_queries),
|
||||
MANGA(R.string.content_type_manga),
|
||||
SOURCES(R.string.remote_sources),
|
||||
RECENT_SOURCES(R.string.recent_sources),
|
||||
AUTHORS(R.string.authors),
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
||||
|
||||
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
|
||||
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
|
||||
is ConfigKey.PreferredImageServer -> prefs.getString(key.key, key.defaultValue)?.takeUnless(String::isEmpty)
|
||||
} as T
|
||||
}
|
||||
|
||||
@@ -47,6 +48,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
||||
is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
|
||||
is ConfigKey.UserAgent -> putString(key.key, (value as String?)?.sanitizeHeaderValue())
|
||||
is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean)
|
||||
is ConfigKey.PreferredImageServer -> putString(key.key, value as String? ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.core.util.KotatsuColors
|
||||
|
||||
class FaviconDrawable(
|
||||
open class FaviconDrawable(
|
||||
context: Context,
|
||||
@StyleRes styleResId: Int,
|
||||
name: String,
|
||||
) : Drawable() {
|
||||
|
||||
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 val letter = name.take(1).uppercase()
|
||||
private var cornerSize = 0f
|
||||
private var colorForeground = Color.DKGRAY
|
||||
private val textBounds = Rect()
|
||||
private val tempRect = Rect()
|
||||
private val boundsF = RectF()
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
package org.koitharu.kotatsu.core.ui.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.graphics.alpha
|
||||
import androidx.core.graphics.blue
|
||||
import androidx.core.graphics.get
|
||||
import androidx.core.graphics.green
|
||||
import androidx.core.graphics.red
|
||||
import coil.size.Size
|
||||
import coil.transform.Transformation
|
||||
import kotlin.math.abs
|
||||
import org.koitharu.kotatsu.reader.domain.EdgeDetector.Companion.isColorTheSame
|
||||
|
||||
class TrimTransformation(
|
||||
private val tolerance: Int = 20,
|
||||
@@ -28,7 +23,7 @@ class TrimTransformation(
|
||||
var isColBlank = true
|
||||
val prevColor = input[x, 0]
|
||||
for (y in 1 until input.height) {
|
||||
if (!isColorTheSame(input[x, y], prevColor)) {
|
||||
if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
|
||||
isColBlank = false
|
||||
break
|
||||
}
|
||||
@@ -47,7 +42,7 @@ class TrimTransformation(
|
||||
var isColBlank = true
|
||||
val prevColor = input[x, 0]
|
||||
for (y in 1 until input.height) {
|
||||
if (!isColorTheSame(input[x, y], prevColor)) {
|
||||
if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
|
||||
isColBlank = false
|
||||
break
|
||||
}
|
||||
@@ -63,7 +58,7 @@ class TrimTransformation(
|
||||
var isRowBlank = true
|
||||
val prevColor = input[0, y]
|
||||
for (x in 1 until input.width) {
|
||||
if (!isColorTheSame(input[x, y], prevColor)) {
|
||||
if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
|
||||
isRowBlank = false
|
||||
break
|
||||
}
|
||||
@@ -79,7 +74,7 @@ class TrimTransformation(
|
||||
var isRowBlank = true
|
||||
val prevColor = input[0, y]
|
||||
for (x in 1 until input.width) {
|
||||
if (!isColorTheSame(input[x, y], prevColor)) {
|
||||
if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
|
||||
isRowBlank = false
|
||||
break
|
||||
}
|
||||
@@ -98,13 +93,6 @@ class TrimTransformation(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isColorTheSame(@ColorInt a: Int, @ColorInt b: Int): Boolean {
|
||||
return abs(a.red - b.red) <= tolerance &&
|
||||
abs(a.green - b.green) <= tolerance &&
|
||||
abs(a.blue - b.blue) <= tolerance &&
|
||||
abs(a.alpha - b.alpha) <= tolerance
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return this === other || (other is TrimTransformation && other.tolerance == tolerance)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package org.koitharu.kotatsu.core.ui.list
|
||||
|
||||
import android.app.Notification.Action
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.collection.LongSet
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
@@ -14,6 +14,8 @@ import androidx.savedstate.SavedStateRegistry
|
||||
import androidx.savedstate.SavedStateRegistryOwner
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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
|
||||
|
||||
private const val KEY_SELECTION = "selection"
|
||||
@@ -35,11 +37,9 @@ class ListSelectionController(
|
||||
registryOwner.lifecycle.addObserver(StateEventObserver())
|
||||
}
|
||||
|
||||
fun snapshot(): Set<Long> {
|
||||
return peekCheckedIds().toSet()
|
||||
}
|
||||
fun snapshot(): Set<Long> = peekCheckedIds().toSet()
|
||||
|
||||
fun peekCheckedIds(): Set<Long> {
|
||||
fun peekCheckedIds(): LongSet {
|
||||
return decoration.checkedItemsIds
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.view.View
|
||||
import androidx.collection.LongSet
|
||||
import androidx.collection.MutableLongSet
|
||||
import androidx.core.view.children
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.NO_ID
|
||||
@@ -12,7 +14,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
|
||||
|
||||
private val bounds = Rect()
|
||||
private val boundsF = RectF()
|
||||
protected val selection = HashSet<Long>()
|
||||
protected val selection = MutableLongSet()
|
||||
|
||||
protected var hasBackground: Boolean = true
|
||||
protected var hasForeground: Boolean = false
|
||||
@@ -21,7 +23,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
|
||||
val checkedItemsCount: Int
|
||||
get() = selection.size
|
||||
|
||||
val checkedItemsIds: Set<Long>
|
||||
val checkedItemsIds: LongSet
|
||||
get() = selection
|
||||
|
||||
fun toggleItemChecked(id: Long) {
|
||||
@@ -39,7 +41,9 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
|
||||
}
|
||||
|
||||
fun checkAll(ids: Collection<Long>) {
|
||||
selection.addAll(ids)
|
||||
for (id in ids) {
|
||||
selection.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
|
||||
@@ -23,11 +23,16 @@ class SelectableTextView @JvmOverloads constructor(
|
||||
private fun fixSelectionRange() {
|
||||
if (selectionStart < 0 || selectionEnd < 0) {
|
||||
val spannableText = text as? Spannable ?: return
|
||||
Selection.setSelection(spannableText, text.length)
|
||||
Selection.setSelection(spannableText, spannableText.length)
|
||||
}
|
||||
}
|
||||
|
||||
override fun scrollTo(x: Int, y: Int) {
|
||||
super.scrollTo(0, 0)
|
||||
}
|
||||
|
||||
fun selectAll() {
|
||||
val spannableText = text as? Spannable ?: return
|
||||
Selection.selectAll(spannableText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import androidx.collection.ArrayMap
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.collection.LongSet
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import java.util.Collections
|
||||
import java.util.EnumSet
|
||||
@@ -69,4 +70,24 @@ fun <T> Iterable<T>.sortedWithSafe(comparator: Comparator<in T>): List<T> = try
|
||||
}
|
||||
}
|
||||
|
||||
fun Collection<*>?.sizeOrZero() = if (this == null) 0 else size
|
||||
fun Collection<*>?.sizeOrZero() = this?.size ?: 0
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
inline fun <T, reified R> Collection<T>.mapToArray(transform: (T) -> R): Array<R> {
|
||||
val result = arrayOfNulls<R>(size)
|
||||
forEachIndexed { index, t -> result[index] = transform(t) }
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -12,12 +12,16 @@ import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.koitharu.kotatsu.core.util.AcraCoroutineErrorHandler
|
||||
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
|
||||
import org.koitharu.kotatsu.parsers.util.cancelAll
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
@@ -90,3 +94,10 @@ fun <T> Deferred<T>.peek(): T? = if (isCompleted) {
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@Suppress("SuspendFunctionOnCoroutineScope")
|
||||
suspend fun CoroutineScope.cancelChildrenAndJoin(cause: CancellationException? = null) {
|
||||
val jobs = coroutineContext[Job]?.children?.toList() ?: return
|
||||
jobs.cancelAll(cause)
|
||||
jobs.joinAll()
|
||||
}
|
||||
|
||||
@@ -37,3 +37,5 @@ fun JSONObject.toContentValues(): ContentValues {
|
||||
}
|
||||
|
||||
private fun String.escapeName() = "`$this`"
|
||||
|
||||
fun Cursor.getBoolean(columnIndex: Int) = getInt(columnIndex) > 0
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -11,3 +12,9 @@ fun Rect.scale(factor: Double) {
|
||||
(height() - newHeight) / 2,
|
||||
)
|
||||
}
|
||||
|
||||
inline fun <R> Bitmap.use(block: (Bitmap) -> R) = try {
|
||||
block(this)
|
||||
} finally {
|
||||
recycle()
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.exceptions.CaughtException
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
|
||||
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
|
||||
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
|
||||
import org.koitharu.kotatsu.core.exceptions.SyncApiException
|
||||
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
|
||||
@@ -60,7 +61,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
|
||||
-> resources.getString(R.string.network_error)
|
||||
|
||||
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
|
||||
|
||||
is IncompatiblePluginException -> resources.getString(R.string.plugin_incompatible)
|
||||
is WrongPasswordException -> resources.getString(R.string.wrong_password)
|
||||
is NotFoundException -> resources.getString(R.string.not_found_404)
|
||||
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
|
||||
|
||||
@@ -55,11 +55,11 @@ class DetailsLoadUseCase @Inject constructor(
|
||||
try {
|
||||
val details = getDetails(manga)
|
||||
launch { updateTracker(details) }
|
||||
send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false), false))
|
||||
send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true), true))
|
||||
send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false)?.trim(), false))
|
||||
send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true)?.trim(), true))
|
||||
} catch (e: IOException) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
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.isLocal
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
@@ -62,7 +64,7 @@ class MangaPrefetchService : CoroutineIntentService() {
|
||||
|
||||
private suspend fun prefetchLast() {
|
||||
val last = historyRepository.getLastOrNull() ?: return
|
||||
if (last.source == MangaSource.LOCAL) return
|
||||
if (last.isLocal) return
|
||||
val repo = mangaRepositoryFactory.create(last.source)
|
||||
val details = runCatchingCancellable { repo.getDetails(last) }.getOrNull() ?: return
|
||||
val chapters = details.chapters
|
||||
@@ -110,7 +112,7 @@ class MangaPrefetchService : CoroutineIntentService() {
|
||||
}
|
||||
|
||||
private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean {
|
||||
if (source == MangaSource.LOCAL || context.isPowerSaveMode()) {
|
||||
if (source == LocalMangaSource || context.isPowerSaveMode()) {
|
||||
return false
|
||||
}
|
||||
val entryPoint = EntryPointAccessors.fromApplication(
|
||||
|
||||
@@ -43,6 +43,9 @@ import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
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.parcelable.ParcelableManga
|
||||
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.favourites.ui.categories.select.FavoriteSheet
|
||||
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.mangaGridItemAD
|
||||
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.local.ui.info.LocalInfoDialog
|
||||
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.util.ellipsize
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
@@ -120,10 +122,9 @@ class DetailsActivity :
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
@Inject
|
||||
lateinit var tagHighlighter: ListExtraProvider
|
||||
lateinit var listMapper: MangaListMapper
|
||||
|
||||
private val viewModel: DetailsViewModel by viewModels()
|
||||
|
||||
private lateinit var menuProvider: DetailsMenuProvider
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -157,6 +158,7 @@ class DetailsActivity :
|
||||
viewBinding.containerBottomSheet?.let { sheet ->
|
||||
onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(sheet))
|
||||
}
|
||||
TitleExpandListener(viewBinding.textViewTitle).attach()
|
||||
|
||||
viewModel.details.filterNotNull().observe(this, ::onMangaUpdated)
|
||||
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
|
||||
@@ -389,7 +391,7 @@ class DetailsActivity :
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRelatedMangaChanged(related: List<MangaItemModel>) {
|
||||
private fun onRelatedMangaChanged(related: List<MangaListModel>) {
|
||||
if (related.isEmpty()) {
|
||||
viewBinding.groupRelated.isVisible = false
|
||||
return
|
||||
@@ -463,10 +465,10 @@ class DetailsActivity :
|
||||
imageViewState.isVisible = false
|
||||
}
|
||||
|
||||
if (manga.source == MangaSource.LOCAL || manga.source == MangaSource.UNKNOWN) {
|
||||
if (manga.source == LocalMangaSource || manga.source == UnknownMangaSource) {
|
||||
infoLayout.chipSource.isVisible = false
|
||||
} else {
|
||||
infoLayout.chipSource.text = manga.source.title
|
||||
infoLayout.chipSource.text = manga.source.getTitle(this@DetailsActivity)
|
||||
infoLayout.chipSource.isVisible = true
|
||||
}
|
||||
|
||||
@@ -611,15 +613,7 @@ class DetailsActivity :
|
||||
|
||||
private fun bindTags(manga: Manga) {
|
||||
viewBinding.chipsTags.isVisible = manga.tags.isNotEmpty()
|
||||
viewBinding.chipsTags.setChips(
|
||||
manga.tags.map { tag ->
|
||||
ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
tint = tagHighlighter.getTagTint(tag),
|
||||
data = tag,
|
||||
)
|
||||
},
|
||||
)
|
||||
viewBinding.chipsTags.setChips(listMapper.mapTags(manga.tags))
|
||||
}
|
||||
|
||||
private fun loadCover(manga: Manga) {
|
||||
|
||||
@@ -16,11 +16,12 @@ import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||
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.search.ui.multi.MultiSearchActivity
|
||||
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
|
||||
@@ -38,10 +39,10 @@ class DetailsMenuProvider(
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
val manga = viewModel.manga.value
|
||||
menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != MangaSource.LOCAL
|
||||
menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL
|
||||
menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL
|
||||
menu.findItem(R.id.action_alternatives).isVisible = manga?.source != MangaSource.LOCAL
|
||||
menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != LocalMangaSource
|
||||
menu.findItem(R.id.action_delete).isVisible = manga?.source == LocalMangaSource
|
||||
menu.findItem(R.id.action_browser).isVisible = manga?.source != LocalMangaSource
|
||||
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_scrobbling).isVisible = viewModel.isScrobblingAvailable
|
||||
menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null
|
||||
@@ -53,7 +54,7 @@ class DetailsMenuProvider(
|
||||
R.id.action_share -> {
|
||||
viewModel.manga.value?.let {
|
||||
val shareHelper = ShareHelper(activity)
|
||||
if (it.source == MangaSource.LOCAL) {
|
||||
if (it.isLocal) {
|
||||
shareHelper.shareCbz(listOf(it.url.toUri().toFile()))
|
||||
} else {
|
||||
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.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
|
||||
import org.koitharu.kotatsu.list.ui.model.toUi
|
||||
import org.koitharu.kotatsu.list.domain.MangaListMapper
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaListModel
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
@@ -76,7 +75,7 @@ class DetailsViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
|
||||
private val relatedMangaUseCase: RelatedMangaUseCase,
|
||||
private val extraProvider: ListExtraProvider,
|
||||
private val mangaListMapper: MangaListMapper,
|
||||
private val detailsLoadUseCase: DetailsLoadUseCase,
|
||||
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
||||
private val readingTimeUseCase: ReadingTimeUseCase,
|
||||
@@ -171,9 +170,12 @@ class DetailsViewModel @Inject constructor(
|
||||
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
|
||||
.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) {
|
||||
relatedMangaUseCase.invoke(it)?.toUi(ListMode.GRID, extraProvider).orEmpty()
|
||||
mangaListMapper.toListModelList(
|
||||
manga = relatedMangaUseCase(it).orEmpty(),
|
||||
mode = ListMode.GRID,
|
||||
)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.transition.TransitionManager
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.View.OnTouchListener
|
||||
import android.view.ViewGroup
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.widgets.SelectableTextView
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
class TitleExpandListener(
|
||||
private val textView: SelectableTextView,
|
||||
) : GestureDetector.SimpleOnGestureListener(), OnTouchListener {
|
||||
|
||||
private val gestureDetector = GestureDetector(textView.context, this)
|
||||
private val linesExpanded = textView.resources.getInteger(R.integer.details_description_lines)
|
||||
private val linesCollapsed = textView.resources.getInteger(R.integer.details_title_lines)
|
||||
|
||||
override fun onTouch(v: View?, event: MotionEvent) = gestureDetector.onTouchEvent(event)
|
||||
|
||||
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
|
||||
if (textView.context.isAnimationsEnabled) {
|
||||
TransitionManager.beginDelayedTransition(textView.parent as ViewGroup)
|
||||
}
|
||||
if (textView.maxLines in 1 until Integer.MAX_VALUE) {
|
||||
textView.maxLines = Integer.MAX_VALUE
|
||||
} else {
|
||||
textView.maxLines = linesCollapsed
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onLongPress(e: MotionEvent) {
|
||||
textView.maxLines = Integer.MAX_VALUE
|
||||
textView.selectAll()
|
||||
}
|
||||
|
||||
fun attach() {
|
||||
textView.setOnTouchListener(this)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,11 @@ fun HistoryInfo(
|
||||
history: MangaHistory?,
|
||||
isIncognitoMode: Boolean
|
||||
): HistoryInfo {
|
||||
val chapters = manga?.chapters?.get(branch)
|
||||
val chapters = if (manga?.chapters?.isEmpty() == true) {
|
||||
emptyList()
|
||||
} else {
|
||||
manga?.chapters?.get(branch)
|
||||
}
|
||||
val currentChapter = if (history != null && !chapters.isNullOrEmpty()) {
|
||||
chapters.indexOfFirst { it.id == history.chapterId }
|
||||
} else {
|
||||
|
||||
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
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.list.ListSelectionController
|
||||
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.observe
|
||||
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.details.ui.DetailsViewModel
|
||||
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.model.ListModel
|
||||
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.ReaderNavigationCallback
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
@@ -137,10 +139,10 @@ class ChaptersFragment :
|
||||
val ids = selectionController?.peekCheckedIds()
|
||||
val manga = viewModel.manga.value
|
||||
when {
|
||||
ids.isNullOrEmpty() || manga == null -> Unit
|
||||
ids == null || ids.isEmpty() || manga == null -> Unit
|
||||
ids.size == manga.chapters?.size -> viewModel.deleteLocal()
|
||||
else -> {
|
||||
LocalChaptersRemoveService.start(requireContext(), manga, ids)
|
||||
LocalChaptersRemoveService.start(requireContext(), manga, ids.toSet())
|
||||
Snackbar.make(
|
||||
requireViewBinding().recyclerViewChapters,
|
||||
R.string.chapters_will_removed_background,
|
||||
@@ -154,7 +156,7 @@ class ChaptersFragment :
|
||||
|
||||
R.id.action_select_range -> {
|
||||
val items = chaptersAdapter?.items ?: return false
|
||||
val ids = HashSet(controller.peekCheckedIds())
|
||||
val ids = controller.peekCheckedIds().toCollection(HashSet())
|
||||
val buffer = HashSet<Long>()
|
||||
var isAdding = false
|
||||
for (x in items) {
|
||||
@@ -188,8 +190,12 @@ class ChaptersFragment :
|
||||
}
|
||||
|
||||
R.id.action_mark_current -> {
|
||||
val id = controller.peekCheckedIds().singleOrNull() ?: return false
|
||||
viewModel.markChapterAsCurrent(id)
|
||||
val ids = controller.peekCheckedIds()
|
||||
if (ids.size == 1) {
|
||||
viewModel.markChapterAsCurrent(ids.first())
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
@@ -218,7 +224,7 @@ class ChaptersFragment :
|
||||
var canSave = true
|
||||
var canDelete = true
|
||||
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
|
||||
}
|
||||
menu.findItem(R.id.action_save).isVisible = canSave
|
||||
|
||||
@@ -92,7 +92,7 @@ class MangaPageFetcher(
|
||||
}
|
||||
|
||||
else -> {
|
||||
val request = PageLoader.createPageRequest(page, pageUrl)
|
||||
val request = PageLoader.createPageRequest(pageUrl, page.source)
|
||||
imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw HttpException(response)
|
||||
|
||||
@@ -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.require
|
||||
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.model.EmptyState
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.list.ui.model.toUi
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -34,7 +33,7 @@ class RelatedListViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
settings: AppSettings,
|
||||
private val extraProvider: ListExtraProvider,
|
||||
private val mangaListMapper: MangaListMapper,
|
||||
downloadScheduler: DownloadWorker.Scheduler,
|
||||
) : MangaListViewModel(settings, downloadScheduler) {
|
||||
|
||||
@@ -46,14 +45,14 @@ class RelatedListViewModel @Inject constructor(
|
||||
|
||||
override val content = combine(
|
||||
mangaList,
|
||||
listMode,
|
||||
observeListModeWithTriggers(),
|
||||
listError,
|
||||
) { list, mode, error ->
|
||||
when {
|
||||
list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true))
|
||||
list == null -> listOf(LoadingState)
|
||||
list.isEmpty() -> listOf(createEmptyState())
|
||||
else -> list.toUi(mode, extraProvider)
|
||||
else -> mangaListMapper.toListModelList(list, mode)
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.download.ui.list
|
||||
|
||||
import androidx.collection.ArrayMap
|
||||
import androidx.collection.LongSet
|
||||
import androidx.collection.LongSparseArray
|
||||
import androidx.collection.getOrElse
|
||||
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.parser.MangaDataRepository
|
||||
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.model.DateTimeAgo
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -325,6 +326,6 @@ class DownloadsViewModel @Inject constructor(
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
|
||||
private suspend fun tryLoad(manga: Manga) = runCatchingCancellable {
|
||||
(mangaRepositoryFactory.create(manga.source) as RemoteMangaRepository).getDetails(manga)
|
||||
(mangaRepositoryFactory.create(manga.source) as ParserMangaRepository).getDetails(manga)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
@@ -21,13 +21,13 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
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.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.download.domain.DownloadState
|
||||
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
|
||||
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.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||
@@ -231,7 +231,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
||||
if (manga != null) {
|
||||
DetailsActivity.newIntent(context, manga)
|
||||
} else {
|
||||
MangaListActivity.newIntent(context, MangaSource.LOCAL)
|
||||
MangaListActivity.newIntent(context, LocalMangaSource)
|
||||
},
|
||||
PendingIntent.FLAG_CANCEL_CURRENT,
|
||||
false,
|
||||
|
||||
@@ -3,7 +3,7 @@ package org.koitharu.kotatsu.download.ui.worker
|
||||
import androidx.collection.MutableObjectLongMap
|
||||
import kotlinx.coroutines.delay
|
||||
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
|
||||
|
||||
class DownloadSlowdownDispatcher(
|
||||
@@ -13,7 +13,7 @@ class DownloadSlowdownDispatcher(
|
||||
private val timeMap = MutableObjectLongMap<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()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -35,16 +35,17 @@ import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.IOException
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.use
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
|
||||
import org.koitharu.kotatsu.core.model.ids
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
@@ -55,6 +56,7 @@ import org.koitharu.kotatsu.core.util.ext.awaitWorkInfosByTag
|
||||
import org.koitharu.kotatsu.core.util.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.core.util.ext.deleteWork
|
||||
import org.koitharu.kotatsu.core.util.ext.deleteWorks
|
||||
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.getWorkInputData
|
||||
import org.koitharu.kotatsu.core.util.ext.getWorkSpec
|
||||
@@ -73,9 +75,9 @@ import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -93,6 +95,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val settings: AppSettings,
|
||||
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
|
||||
private val imageProxyInterceptor: ImageProxyInterceptor,
|
||||
notificationFactoryFactory: DownloadNotificationFactory.Factory,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
@@ -177,7 +180,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) }
|
||||
var output: LocalMangaOutput? = null
|
||||
try {
|
||||
if (manga.source == MangaSource.LOCAL) {
|
||||
if (manga.isLocal) {
|
||||
manga = localMangaRepository.getRemoteManga(manga)
|
||||
?: error("Cannot obtain remote manga instance")
|
||||
}
|
||||
@@ -327,28 +330,24 @@ class DownloadWorker @AssistedInject constructor(
|
||||
destination: File,
|
||||
source: MangaSource,
|
||||
): File {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.tag(MangaSource::class.java, source)
|
||||
.header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8")
|
||||
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
|
||||
.get()
|
||||
.build()
|
||||
val request = PageLoader.createPageRequest(url, source)
|
||||
slowdownDispatcher.delay(source)
|
||||
val call = okHttp.newCall(request)
|
||||
val file = File(destination, UUID.randomUUID().toString() + ".tmp")
|
||||
try {
|
||||
val response = call.clone().await()
|
||||
checkNotNull(response.body).use { body ->
|
||||
file.sink(append = false).buffer().use {
|
||||
it.writeAllCancellable(body.source())
|
||||
return imageProxyInterceptor.interceptPageRequest(request, okHttp)
|
||||
.ensureSuccess()
|
||||
.use { response ->
|
||||
val file = File(destination, UUID.randomUUID().toString() + ".tmp")
|
||||
try {
|
||||
checkNotNull(response.body).use { body ->
|
||||
file.sink(append = false).buffer().use {
|
||||
it.writeAllCancellable(body.source())
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
file.delete()
|
||||
throw e
|
||||
}
|
||||
file
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
file.delete()
|
||||
throw e
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
private suspend fun publishState(state: DownloadState) {
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
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 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.callbackFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
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.dao.MangaSourcesDao
|
||||
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.parser.external.ExternalMangaSource
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
|
||||
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.util.mapNotNullToSet
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import java.util.Collections
|
||||
import java.util.EnumSet
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Reusable
|
||||
@Singleton
|
||||
class MangaSourcesRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: MangaDatabase,
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
@@ -34,21 +49,38 @@ class MangaSourcesRepository @Inject constructor(
|
||||
private val dao: MangaSourcesDao
|
||||
get() = db.getSourcesDao()
|
||||
|
||||
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
|
||||
remove(MangaSource.LOCAL)
|
||||
remove(MangaSource.UNKNOWN)
|
||||
private val remoteSources = EnumSet.allOf(MangaParserSource::class.java).apply {
|
||||
if (!BuildConfig.DEBUG) {
|
||||
remove(MangaSource.DUMMY)
|
||||
remove(MangaParserSource.DUMMY)
|
||||
}
|
||||
}
|
||||
|
||||
val allMangaSources: Set<MangaSource>
|
||||
val allMangaSources: Set<MangaParserSource>
|
||||
get() = Collections.unmodifiableSet(remoteSources)
|
||||
|
||||
suspend fun getEnabledSources(): List<MangaSource> {
|
||||
assimilateNewSources()
|
||||
val order = settings.sourcesSortOrder
|
||||
return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order)
|
||||
return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order).let { enabled ->
|
||||
val external = getExternalSources()
|
||||
val list = ArrayList<MangaSourceInfo>(enabled.size + external.size)
|
||||
external.mapTo(list) { MangaSourceInfo(it, isEnabled = true, isPinned = true) }
|
||||
list.addAll(enabled)
|
||||
list
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getPinnedSources(): Set<MangaSource> {
|
||||
assimilateNewSources()
|
||||
val skipNsfw = settings.isNsfwContentDisabled
|
||||
return dao.findAllPinned().mapNotNullToSet {
|
||||
it.source.toMangaSourceOrNull()?.takeUnless { x -> skipNsfw && x.isNsfw() }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getTopSources(limit: Int): List<MangaSource> {
|
||||
assimilateNewSources()
|
||||
return dao.findLastUsed(limit).toSources(settings.isNsfwContentDisabled, null)
|
||||
}
|
||||
|
||||
suspend fun getDisabledSources(): Set<MangaSource> {
|
||||
@@ -62,7 +94,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun getAvailableSources(
|
||||
suspend fun queryParserSources(
|
||||
isDisabledOnly: Boolean,
|
||||
isNewOnly: Boolean,
|
||||
excludeBroken: Boolean,
|
||||
@@ -70,7 +102,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
query: String?,
|
||||
locale: String?,
|
||||
sortOrder: SourcesSortOrder?,
|
||||
): List<MangaSource> {
|
||||
): List<MangaParserSource> {
|
||||
assimilateNewSources()
|
||||
val entities = dao.findAll().toMutableList()
|
||||
if (isDisabledOnly) {
|
||||
@@ -82,7 +114,9 @@ class MangaSourcesRepository @Inject constructor(
|
||||
val sources = entities.toSources(
|
||||
skipNsfwSources = settings.isNsfwContentDisabled,
|
||||
sortOrder = sortOrder,
|
||||
)
|
||||
).run {
|
||||
mapNotNullTo(ArrayList(size)) { it.mangaSource as? MangaParserSource }
|
||||
}
|
||||
if (locale != null) {
|
||||
sources.retainAll { it.locale == locale }
|
||||
}
|
||||
@@ -94,7 +128,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
}
|
||||
if (!query.isNullOrEmpty()) {
|
||||
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
|
||||
@@ -127,14 +161,21 @@ class MangaSourcesRepository @Inject constructor(
|
||||
}.distinctUntilChanged().onStart { assimilateNewSources() }
|
||||
}
|
||||
|
||||
fun observeEnabledSources(): Flow<List<MangaSource>> = combine(
|
||||
fun observeEnabledSources(): Flow<List<MangaSourceInfo>> = combine(
|
||||
observeIsNsfwDisabled(),
|
||||
observeSortOrder(),
|
||||
) { skipNsfw, order ->
|
||||
dao.observeEnabled(order).map {
|
||||
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 ->
|
||||
val result = ArrayList<Pair<MangaSource, Boolean>>(entities.size)
|
||||
@@ -214,6 +255,8 @@ class MangaSourcesRepository @Inject constructor(
|
||||
isEnabled = false,
|
||||
sortKey = ++maxSortKey,
|
||||
addedIn = BuildConfig.VERSION_CODE,
|
||||
lastUsedAt = 0,
|
||||
isPinned = false,
|
||||
)
|
||||
}
|
||||
dao.insertIfAbsent(entities)
|
||||
@@ -224,6 +267,19 @@ class MangaSourcesRepository @Inject constructor(
|
||||
return settings.sourcesVersion == 0 && dao.findAllEnabledNames().isEmpty()
|
||||
}
|
||||
|
||||
suspend fun setIsPinned(sources: Collection<MangaSource>, isPinned: Boolean): ReversibleHandle {
|
||||
setSourcesPinnedImpl(sources, isPinned)
|
||||
return ReversibleHandle {
|
||||
setSourcesEnabledImpl(sources, !isPinned)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun trackUsage(source: MangaSource) {
|
||||
if (!settings.isIncognitoModeEnabled && !(settings.isHistoryExcludeNsfw && source.isNsfw())) {
|
||||
dao.setLastUsed(source.name, System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setSourcesEnabledImpl(sources: Collection<MangaSource>, isEnabled: Boolean) {
|
||||
if (sources.size == 1) { // fast path
|
||||
dao.setEnabled(sources.first().name, isEnabled)
|
||||
@@ -236,7 +292,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getNewSources(): MutableSet<MangaSource> {
|
||||
private suspend fun getNewSources(): MutableSet<out MangaSource> {
|
||||
val entities = dao.findAll()
|
||||
val result = EnumSet.copyOf(remoteSources)
|
||||
for (e in entities) {
|
||||
@@ -245,22 +301,77 @@ class MangaSourcesRepository @Inject constructor(
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun setSourcesPinnedImpl(sources: Collection<MangaSource>, isPinned: Boolean) {
|
||||
if (sources.size == 1) { // fast path
|
||||
dao.setPinned(sources.first().name, isPinned)
|
||||
return
|
||||
}
|
||||
db.withTransaction {
|
||||
for (source in sources) {
|
||||
dao.setPinned(source.name, isPinned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeExternalSources(): Flow<List<ExternalMangaSource>> {
|
||||
return callbackFlow {
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
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 {
|
||||
getExternalSources()
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
private fun getExternalSources() = context.packageManager.queryIntentContentProviders(
|
||||
Intent("app.kotatsu.parser.PROVIDE_MANGA"), 0,
|
||||
).map { resolveInfo ->
|
||||
ExternalMangaSource(
|
||||
packageName = resolveInfo.providerInfo.packageName,
|
||||
authority = resolveInfo.providerInfo.authority,
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<MangaSourceEntity>.toSources(
|
||||
skipNsfwSources: Boolean,
|
||||
sortOrder: SourcesSortOrder?,
|
||||
): MutableList<MangaSource> {
|
||||
val result = ArrayList<MangaSource>(size)
|
||||
): MutableList<MangaSourceInfo> {
|
||||
val result = ArrayList<MangaSourceInfo>(size)
|
||||
for (entity in this) {
|
||||
val source = entity.source.toMangaSourceOrNull() ?: continue
|
||||
if (skipNsfwSources && source.isNsfw()) {
|
||||
continue
|
||||
}
|
||||
if (source in remoteSources) {
|
||||
result.add(source)
|
||||
result.add(
|
||||
MangaSourceInfo(
|
||||
mangaSource = source,
|
||||
isEnabled = entity.isEnabled,
|
||||
isPinned = entity.isPinned,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (sortOrder == SourcesSortOrder.ALPHABETIC) {
|
||||
result.sortBy { it.title }
|
||||
result.sortWith(compareBy<MangaSourceInfo> { !it.isPinned }.thenBy { it.getTitle(context) })
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -273,5 +384,5 @@ class MangaSourcesRepository @Inject constructor(
|
||||
sourcesSortOrder
|
||||
}
|
||||
|
||||
private fun String.toMangaSourceOrNull(): MangaSource? = MangaSource.entries.find { it.name == this }
|
||||
private fun String.toMangaSourceOrNull(): MangaParserSource? = MangaParserSource.entries.find { it.name == this }
|
||||
}
|
||||
|
||||
@@ -9,4 +9,5 @@ enum class SourcesSortOrder(
|
||||
ALPHABETIC(R.string.by_name),
|
||||
POPULARITY(R.string.popular),
|
||||
MANUAL(R.string.manual),
|
||||
LAST_USED(R.string.last_used),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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.prefs.AppSettings
|
||||
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.explore.data.MangaSourcesRepository
|
||||
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.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
@@ -45,7 +45,7 @@ class ExploreRepository @Inject constructor(
|
||||
|
||||
suspend fun findRandomManga(source: MangaSource, tagsLimit: Int): Manga {
|
||||
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 {
|
||||
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.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
@@ -20,6 +22,8 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
|
||||
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.dialog.TwoButtonsAlertDialog
|
||||
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.model.ListHeader
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
|
||||
@@ -123,7 +126,7 @@ class ExploreFragment :
|
||||
|
||||
override fun onClick(v: View) {
|
||||
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_more -> SuggestionsActivity.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 {
|
||||
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_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)
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
|
||||
val selectedSources = controller.peekCheckedIds().mapNotNullToSet { id ->
|
||||
MangaSource.entries.getOrNull(id.toInt())
|
||||
}
|
||||
val selectedSources = viewModel.sourcesSnapshot(controller.peekCheckedIds())
|
||||
if (selectedSources.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
@@ -190,12 +196,29 @@ class ExploreFragment :
|
||||
mode.finish()
|
||||
}
|
||||
|
||||
R.id.action_delete -> {
|
||||
selectedSources.forEach {
|
||||
(it.mangaSource as? ExternalMangaSource)?.let { uninstallExternalSource(it) }
|
||||
}
|
||||
mode.finish()
|
||||
}
|
||||
|
||||
R.id.action_shortcut -> {
|
||||
val source = selectedSources.singleOrNull() ?: return false
|
||||
viewModel.requestPinShortcut(source)
|
||||
mode.finish()
|
||||
}
|
||||
|
||||
R.id.action_pin -> {
|
||||
viewModel.setSourcesPinned(selectedSources, isPinned = true)
|
||||
mode.finish()
|
||||
}
|
||||
|
||||
R.id.action_unpin -> {
|
||||
viewModel.setSourcesPinned(selectedSources, isPinned = false)
|
||||
mode.finish()
|
||||
}
|
||||
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
@@ -228,4 +251,14 @@ class ExploreFragment :
|
||||
.create()
|
||||
.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
|
||||
|
||||
import androidx.collection.LongSet
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -13,6 +14,7 @@ import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.MangaSourceInfo
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
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.MangaSourceItem
|
||||
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.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
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.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
@@ -108,11 +109,29 @@ class ExploreViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setSourcesPinned(sources: Collection<MangaSource>, isPinned: Boolean) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
sourcesRepository.setIsPinned(sources, isPinned)
|
||||
val message = if (sources.size == 1) {
|
||||
if (isPinned) R.string.source_pinned else R.string.source_unpinned
|
||||
} else {
|
||||
if (isPinned) R.string.sources_pinned else R.string.sources_unpinned
|
||||
}
|
||||
onActionDone.call(ReversibleAction(message, null))
|
||||
}
|
||||
}
|
||||
|
||||
fun respondSuggestionTip(isAccepted: Boolean) {
|
||||
settings.isSuggestionsEnabled = isAccepted
|
||||
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(
|
||||
sourcesRepository.observeEnabledSources(),
|
||||
getSuggestionFlow(),
|
||||
@@ -124,7 +143,7 @@ class ExploreViewModel @Inject constructor(
|
||||
}.withErrorHandling()
|
||||
|
||||
private fun buildList(
|
||||
sources: List<MangaSource>,
|
||||
sources: List<MangaSourceInfo>,
|
||||
recommendation: List<Manga>,
|
||||
isGrid: Boolean,
|
||||
randomLoading: Boolean,
|
||||
@@ -170,14 +189,15 @@ class ExploreViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun List<Manga>.toRecommendationList() = map { manga ->
|
||||
MangaListModel(
|
||||
MangaCompactListModel(
|
||||
id = manga.id,
|
||||
title = manga.title,
|
||||
subtitle = manga.tags.joinToString { it.title },
|
||||
coverUrl = manga.coverUrl,
|
||||
manga = manga,
|
||||
counter = 0,
|
||||
progress = PROGRESS_NONE,
|
||||
progress = null,
|
||||
isFavorite = false,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.explore.ui.adapter
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
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.parser.favicon.faviconUri
|
||||
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.TrimTransformation
|
||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
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.newImageRequest
|
||||
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.list.ui.adapter.ListItemType
|
||||
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
|
||||
|
||||
fun exploreButtonsAD(
|
||||
@@ -63,7 +66,7 @@ fun exploreRecommendationItemAD(
|
||||
{ layoutInflater, parent -> ItemRecommendationBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
val adapter = BaseListAdapter<MangaListModel>()
|
||||
val adapter = BaseListAdapter<MangaCompactListModel>()
|
||||
.addDelegate(ListItemType.MANGA_LIST, recommendationMangaItemAD(coil, itemClickListener, lifecycleOwner))
|
||||
binding.pager.adapter = adapter
|
||||
binding.pager.recyclerView?.isNestedScrollingEnabled = false
|
||||
@@ -78,7 +81,7 @@ fun recommendationMangaItemAD(
|
||||
coil: ImageLoader,
|
||||
itemClickListener: OnListItemClickListener<Manga>,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) = adapterDelegateViewBinding<MangaListModel, MangaListModel, ItemRecommendationMangaBinding>(
|
||||
) = adapterDelegateViewBinding<MangaCompactListModel, MangaCompactListModel, ItemRecommendationMangaBinding>(
|
||||
{ layoutInflater, parent -> ItemRecommendationMangaBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
@@ -115,6 +118,7 @@ fun exploreSourceListItemAD(
|
||||
) {
|
||||
|
||||
val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
|
||||
val iconPinned = ContextCompat.getDrawable(context, R.drawable.ic_pin_small)
|
||||
|
||||
binding.root.setOnClickListener(eventListener)
|
||||
binding.root.setOnLongClickListener(eventListener)
|
||||
@@ -122,11 +126,12 @@ fun exploreSourceListItemAD(
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.source.getTitle(context)
|
||||
binding.textViewTitle.drawableStart = if (item.source.isPinned) iconPinned else null
|
||||
binding.textViewSubtitle.text = item.source.getSummary(context)
|
||||
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
|
||||
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
|
||||
fallback(fallbackIcon)
|
||||
placeholder(fallbackIcon)
|
||||
placeholder(AnimatedFaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name))
|
||||
error(fallbackIcon)
|
||||
source(item.source)
|
||||
enqueueWith(coil)
|
||||
@@ -150,6 +155,7 @@ fun exploreSourceGridItemAD(
|
||||
) {
|
||||
|
||||
val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
|
||||
val iconPinned = ContextCompat.getDrawable(context, R.drawable.ic_pin_small)
|
||||
|
||||
binding.root.setOnClickListener(eventListener)
|
||||
binding.root.setOnLongClickListener(eventListener)
|
||||
@@ -157,10 +163,11 @@ fun exploreSourceGridItemAD(
|
||||
|
||||
bind {
|
||||
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)
|
||||
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
|
||||
fallback(fallbackIcon)
|
||||
placeholder(fallbackIcon)
|
||||
placeholder(AnimatedFaviconDrawable(context, R.style.FaviconDrawable_Large, item.source.name))
|
||||
error(fallbackIcon)
|
||||
source(item.source)
|
||||
enqueueWith(coil)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
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.parsers.model.MangaSource
|
||||
|
||||
data class MangaSourceItem(
|
||||
val source: MangaSource,
|
||||
val source: MangaSourceInfo,
|
||||
val isGrid: Boolean,
|
||||
) : ListModel {
|
||||
|
||||
val id: Long
|
||||
get() = source.ordinal.toLong()
|
||||
val id: Long = source.name.longHashCode()
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
return other is MangaSourceItem && other.source == source
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package org.koitharu.kotatsu.explore.ui.model
|
||||
|
||||
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(
|
||||
val manga: List<MangaListModel>
|
||||
val manga: List<MangaCompactListModel>
|
||||
) : ListModel {
|
||||
|
||||
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")
|
||||
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(onConflict = OnConflictStrategy.REPLACE)
|
||||
|
||||
@@ -115,6 +115,10 @@ class FavouritesRepository @Inject constructor(
|
||||
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> {
|
||||
return db.getFavouritesDao().findCategoriesIds(mangaIds).toSet()
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package org.koitharu.kotatsu.favourites.domain.model
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.find
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
|
||||
data class Cover(
|
||||
val url: String,
|
||||
val source: String,
|
||||
) {
|
||||
val mangaSource: MangaSource?
|
||||
get() = if (source.isEmpty()) null else MangaSource.entries.find(source)
|
||||
val mangaSource by lazy { MangaSource(source) }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.favourites.ui.categories
|
||||
|
||||
import androidx.collection.LongSet
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
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()
|
||||
return items.mapNotNullTo(ArrayList(ids.size)) { item ->
|
||||
(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 dagger.hilt.android.AndroidEntryPoint
|
||||
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.util.ext.sortedByOrdinal
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
@AndroidEntryPoint
|
||||
class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener {
|
||||
@@ -57,9 +57,7 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
|
||||
}
|
||||
|
||||
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none {
|
||||
it.source == MangaSource.LOCAL
|
||||
}
|
||||
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { it.isLocal }
|
||||
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.NO_ID
|
||||
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.MangaListMapper
|
||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.list.ui.model.toUi
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
@@ -41,7 +40,7 @@ private const val PAGE_SIZE = 20
|
||||
class FavouritesListViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val repository: FavouritesRepository,
|
||||
private val listExtraProvider: ListExtraProvider,
|
||||
private val mangaListMapper: MangaListMapper,
|
||||
private val markAsReadUseCase: MarkAsReadUseCase,
|
||||
settings: AppSettings,
|
||||
downloadScheduler: DownloadWorker.Scheduler,
|
||||
@@ -67,7 +66,7 @@ class FavouritesListViewModel @Inject constructor(
|
||||
|
||||
override val content = combine(
|
||||
observeFavorites(),
|
||||
listMode,
|
||||
observeListModeWithTriggers(),
|
||||
refreshTrigger,
|
||||
) { list, mode, _ ->
|
||||
when {
|
||||
@@ -86,7 +85,7 @@ class FavouritesListViewModel @Inject constructor(
|
||||
|
||||
else -> {
|
||||
isReady.set(true)
|
||||
list.toUi(mode, listExtraProvider)
|
||||
mangaListMapper.toListModelList(list, mode)
|
||||
}
|
||||
}
|
||||
}.catch {
|
||||
|
||||
@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
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.MangaRepository
|
||||
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.lifecycleScope
|
||||
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.FilterProperty
|
||||
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.parsers.model.ContentRating
|
||||
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.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
@@ -66,7 +67,7 @@ class FilterCoordinator @Inject constructor(
|
||||
) : MangaFilter {
|
||||
|
||||
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(
|
||||
MangaListFilter.Advanced(
|
||||
sortOrder = repository.defaultSortOrder,
|
||||
@@ -451,7 +452,7 @@ class FilterCoordinator @Inject constructor(
|
||||
}
|
||||
|
||||
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(primary)
|
||||
return result
|
||||
|
||||
@@ -88,7 +88,7 @@ abstract class HistoryDao {
|
||||
abstract suspend fun insert(entity: HistoryEntity): Long
|
||||
|
||||
@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(
|
||||
mangaId: Long,
|
||||
@@ -96,6 +96,7 @@ abstract class HistoryDao {
|
||||
chapterId: Long,
|
||||
scroll: Float,
|
||||
percent: Float,
|
||||
chapters: Int,
|
||||
updatedAt: Long,
|
||||
): Int
|
||||
|
||||
@@ -116,6 +117,7 @@ abstract class HistoryDao {
|
||||
chapterId = entity.chapterId,
|
||||
scroll = entity.scroll,
|
||||
percent = entity.percent,
|
||||
chapters = entity.chaptersCount,
|
||||
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.parser.MangaDataRepository
|
||||
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.util.ext.mapItems
|
||||
import org.koitharu.kotatsu.history.domain.model.MangaWithHistory
|
||||
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.MangaTag
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
|
||||
@@ -102,6 +104,7 @@ class HistoryRepository @Inject constructor(
|
||||
assert(manga.chapters != null)
|
||||
db.withTransaction {
|
||||
mangaRepository.storeManga(manga)
|
||||
val branch = manga.chapters?.findById(chapterId)?.branch
|
||||
db.getHistoryDao().upsert(
|
||||
HistoryEntity(
|
||||
mangaId = manga.id,
|
||||
@@ -111,7 +114,7 @@ class HistoryRepository @Inject constructor(
|
||||
page = page,
|
||||
scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
|
||||
percent = percent,
|
||||
chaptersCount = manga.chapters?.size ?: -1,
|
||||
chaptersCount = manga.chapters?.count { it.branch == branch } ?: 0,
|
||||
deletedAt = 0L,
|
||||
),
|
||||
)
|
||||
@@ -124,8 +127,13 @@ class HistoryRepository @Inject constructor(
|
||||
return db.getHistoryDao().find(manga.id)?.recoverIfNeeded(manga)?.toMangaHistory()
|
||||
}
|
||||
|
||||
suspend fun getProgress(mangaId: Long): Float {
|
||||
return db.getHistoryDao().findProgress(mangaId) ?: PROGRESS_NONE
|
||||
suspend fun getProgress(mangaId: Long, mode: ProgressIndicatorMode): ReadingProgress? {
|
||||
val entity = db.getHistoryDao().find(mangaId) ?: return null
|
||||
return ReadingProgress(
|
||||
percent = entity.percent,
|
||||
totalChapters = entity.chaptersCount,
|
||||
mode = mode,
|
||||
).takeIf { it.isValid() }
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.fragment.app.viewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.os.NetworkManageIntent
|
||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
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.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
@AndroidEntryPoint
|
||||
class HistoryListFragment : MangaListFragment() {
|
||||
@@ -44,9 +44,7 @@ class HistoryListFragment : MangaListFragment() {
|
||||
}
|
||||
|
||||
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none {
|
||||
it.source == MangaSource.LOCAL
|
||||
}
|
||||
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { it.isLocal }
|
||||
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.domain.MarkAsReadUseCase
|
||||
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.MangaListMapper
|
||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyHint
|
||||
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.TipModel
|
||||
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.parsers.model.Manga
|
||||
import java.time.Instant
|
||||
@@ -53,7 +50,7 @@ private const val PAGE_SIZE = 20
|
||||
class HistoryListViewModel @Inject constructor(
|
||||
private val repository: HistoryRepository,
|
||||
settings: AppSettings,
|
||||
private val extraProvider: ListExtraProvider,
|
||||
private val mangaListMapper: MangaListMapper,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
private val markAsReadUseCase: MarkAsReadUseCase,
|
||||
networkState: NetworkState,
|
||||
@@ -91,7 +88,7 @@ class HistoryListViewModel @Inject constructor(
|
||||
override val content = combine(
|
||||
observeHistory(),
|
||||
isGroupingEnabled,
|
||||
listMode,
|
||||
observeListModeWithTriggers(),
|
||||
networkState,
|
||||
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled },
|
||||
) { list, grouped, mode, online, incognito ->
|
||||
@@ -203,11 +200,7 @@ class HistoryListViewModel @Inject constructor(
|
||||
prevHeader = header
|
||||
}
|
||||
}
|
||||
result += when (mode) {
|
||||
ListMode.LIST -> manga.toListModel(extraProvider)
|
||||
ListMode.DETAILED_LIST -> manga.toListDetailedModel(extraProvider)
|
||||
ListMode.GRID -> manga.toGridModel(extraProvider)
|
||||
}
|
||||
result += mangaListMapper.toListModel(manga, mode)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ class ReadingProgressDrawable(
|
||||
private val outlineColor: Int
|
||||
private val backgroundColor: Int
|
||||
private val textColor: Int
|
||||
private val textPattern = context.getString(R.string.percent_string_pattern)
|
||||
private val textBounds = Rect()
|
||||
private val tempRect = Rect()
|
||||
private val hasBackground: Boolean
|
||||
@@ -36,14 +35,18 @@ class ReadingProgressDrawable(
|
||||
private val desiredWidth: Int
|
||||
private val autoFitTextSize: Boolean
|
||||
|
||||
var progress: Float = PROGRESS_NONE
|
||||
var percent: Float = PROGRESS_NONE
|
||||
set(value) {
|
||||
field = value
|
||||
invalidateSelf()
|
||||
}
|
||||
|
||||
var text = ""
|
||||
set(value) {
|
||||
field = value
|
||||
text = textPattern.format((value * 100f).toInt().toString())
|
||||
paint.getTextBounds(text, 0, text.length, textBounds)
|
||||
invalidateSelf()
|
||||
}
|
||||
private var text = ""
|
||||
|
||||
init {
|
||||
val ta = context.obtainStyledAttributes(styleResId, R.styleable.ProgressDrawable)
|
||||
@@ -79,7 +82,7 @@ class ReadingProgressDrawable(
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
if (progress < 0f) {
|
||||
if (percent < 0f) {
|
||||
return
|
||||
}
|
||||
val cx = bounds.exactCenterX()
|
||||
@@ -103,12 +106,12 @@ class ReadingProgressDrawable(
|
||||
cx + innerRadius,
|
||||
cy + innerRadius,
|
||||
-90f,
|
||||
360f * progress,
|
||||
360f * percent,
|
||||
false,
|
||||
paint,
|
||||
)
|
||||
if (hasText) {
|
||||
if (checkDrawable != null && progress >= 1f - Math.ulp(progress)) {
|
||||
if (checkDrawable != null && percent >= 1f - Math.ulp(percent)) {
|
||||
tempRect.set(bounds)
|
||||
tempRect.scale(0.6)
|
||||
checkDrawable.bounds = tempRect
|
||||
|
||||
@@ -11,8 +11,14 @@ import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.StyleRes
|
||||
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.history.data.PROGRESS_NONE
|
||||
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
||||
|
||||
class ReadingProgressView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
@@ -20,17 +26,30 @@ class ReadingProgressView @JvmOverloads constructor(
|
||||
@AttrRes defStyleAttr: Int = 0,
|
||||
) : View(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener, Animator.AnimatorListener {
|
||||
|
||||
private val percentPattern = context.getString(R.string.percent_string_pattern)
|
||||
private var percentAnimator: ValueAnimator? = null
|
||||
private val animationDuration = context.getAnimationDuration(android.R.integer.config_shortAnimTime)
|
||||
|
||||
@StyleRes
|
||||
private val drawableStyle: Int
|
||||
|
||||
var percent: Float
|
||||
get() = peekProgressDrawable()?.progress ?: PROGRESS_NONE
|
||||
var progress: ReadingProgress? = null
|
||||
set(value) {
|
||||
field = value
|
||||
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 {
|
||||
@@ -39,7 +58,11 @@ class ReadingProgressView @JvmOverloads constructor(
|
||||
ta.recycle()
|
||||
outlineProvider = OutlineProvider()
|
||||
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) {
|
||||
val p = animation.animatedValue as Float
|
||||
getProgressDrawable().progress = p
|
||||
getProgressDrawable().percent = p
|
||||
}
|
||||
|
||||
override fun onAnimationStart(animation: Animator) = Unit
|
||||
@@ -68,16 +91,25 @@ class ReadingProgressView @JvmOverloads constructor(
|
||||
|
||||
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()
|
||||
if (!animate || currentDrawable == null || value == PROGRESS_NONE) {
|
||||
percent = value
|
||||
if (!animate || currentDrawable == null || value == null) {
|
||||
progress = value
|
||||
return
|
||||
}
|
||||
percentAnimator?.cancel()
|
||||
val currentPercent = currentDrawable.percent.coerceAtLeast(0f)
|
||||
progress = value.copy(percent = currentPercent)
|
||||
percentAnimator = ValueAnimator.ofFloat(
|
||||
currentDrawable.progress.coerceAtLeast(0f),
|
||||
value,
|
||||
currentDrawable.percent.coerceAtLeast(0f),
|
||||
value.percent,
|
||||
).apply {
|
||||
duration = animationDuration
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
|
||||
@@ -25,13 +25,13 @@ import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
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.util.PopupMenuMediator
|
||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayIcon
|
||||
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.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
@@ -120,7 +120,7 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
|
||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||
.lifecycle(this)
|
||||
.listener(this)
|
||||
.source(intent.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE))
|
||||
.source(MangaSource(intent.getStringExtra(EXTRA_SOURCE)))
|
||||
.target(SsivTarget(viewBinding.ssiv))
|
||||
.enqueueWith(coil)
|
||||
}
|
||||
@@ -180,7 +180,7 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
|
||||
fun newIntent(context: Context, url: String, source: MangaSource?): Intent {
|
||||
return Intent(context, ImageActivity::class.java)
|
||||
.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
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user