Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f518acb8ee | ||
|
|
b39a51d497 | ||
|
|
8819d8b1ee | ||
|
|
05a502b89a | ||
|
|
c320e3c26a | ||
|
|
938849c31e | ||
|
|
95c243daa1 | ||
|
|
6ce6a02b56 | ||
|
|
e92e9fb393 | ||
|
|
f4186a2787 | ||
|
|
8b93b699d3 | ||
|
|
7e13482ba5 | ||
|
|
04700a22c8 | ||
|
|
549d08cc06 | ||
|
|
0fccaf3fbc | ||
|
|
c7e0a47bee | ||
|
|
d527b6e390 | ||
|
|
12b2af6b93 | ||
|
|
63f4fab40f | ||
|
|
9a444cf965 | ||
|
|
b8be2f7158 | ||
|
|
9e2074040f | ||
|
|
020c151e31 | ||
|
|
52eb33a992 | ||
|
|
907b8fd0ec | ||
|
|
e35b2088a1 | ||
|
|
fbb4efb3df | ||
|
|
7ff47a322e | ||
|
|
fda1af5500 | ||
|
|
d88847d137 | ||
|
|
063527b240 | ||
|
|
b0470110a8 | ||
|
|
5a2a31d1c8 | ||
|
|
3b009d7c55 | ||
|
|
f7e937f2b8 | ||
|
|
16e23cc1cf | ||
|
|
d12528d80f | ||
|
|
9f04c7b148 | ||
|
|
7a3942f100 | ||
|
|
8e46f64f2a | ||
|
|
44c50fca2d | ||
|
|
55b4d14a93 | ||
|
|
743693299f | ||
|
|
7950a685a6 | ||
|
|
97cfcb5c01 | ||
|
|
b2dfcefee8 | ||
|
|
ee1ade40c3 | ||
|
|
3690e15cff | ||
|
|
a955dfbe50 | ||
|
|
5e9daa1206 | ||
|
|
a3c2956a4d | ||
|
|
10ecd92715 | ||
|
|
37d2d986ef | ||
|
|
0aadd6ebe2 | ||
|
|
c23ec9a4b8 | ||
|
|
22a37923f9 | ||
|
|
3fc506b438 | ||
|
|
e98dbd5069 | ||
|
|
2a469b27c5 | ||
|
|
0f3ef4559f | ||
|
|
a87ef0a0a6 | ||
|
|
a7a0a7f0db | ||
|
|
bc4622d610 | ||
|
|
8365603bf1 | ||
|
|
b1eabdba79 | ||
|
|
169e31e9ba | ||
|
|
66644d55a4 | ||
|
|
98314960cf | ||
|
|
b73e44874d | ||
|
|
6f45a44070 | ||
|
|
d9d11d685e | ||
|
|
5359267b5a | ||
|
|
a6662ab501 | ||
|
|
699a619c27 | ||
|
|
85ccbbf719 | ||
|
|
a396b33f3d | ||
|
|
6076f775c3 | ||
|
|
379fa88b4e | ||
|
|
9b24c507c5 | ||
|
|
98bd79f0be | ||
|
|
f09e28e782 | ||
|
|
b601b07586 | ||
|
|
73cea59691 | ||
|
|
e2993d47b6 | ||
|
|
2cd67e7cf8 | ||
|
|
c51da5a9d5 | ||
|
|
bcfce29610 | ||
|
|
a87d18fae3 | ||
|
|
bbd421445c | ||
|
|
f4e3d797dc | ||
|
|
bd65cbb8b8 | ||
|
|
7d1f81607a | ||
|
|
3b6cd0ea7f | ||
|
|
aff70d8519 | ||
|
|
8a74faa4f0 | ||
|
|
c1ac207809 | ||
|
|
e34e745c84 | ||
|
|
50dd119ab5 | ||
|
|
d0ef177d56 |
17
README.md
17
README.md
@@ -1,8 +1,8 @@
|
||||
# Kotatsu
|
||||
|
||||
Kotatsu is a free and open source manga reader for Android.
|
||||
Kotatsu is a free and open-source manga reader for Android with built-in online content sources.
|
||||
|
||||
   [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5)
|
||||
[](https://github.com/KotatsuApp/kotatsu-parsers)  [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||
|
||||
### Download
|
||||
|
||||
@@ -12,16 +12,15 @@ Kotatsu is a free and open source manga reader for Android.
|
||||
### Main Features
|
||||
|
||||
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
|
||||
* Search manga by name and genres
|
||||
* Search manga by name, genres, and more filters
|
||||
* Reading history and bookmarks
|
||||
* Favourites organized by user-defined categories
|
||||
* Favorites organized by user-defined categories
|
||||
* Downloading manga and reading it offline. Third-party CBZ archives also supported
|
||||
* Tablet-optimized Material You UI
|
||||
* Standard and Webtoon-optimized reader
|
||||
* Standard and Webtoon-optimized customizable reader
|
||||
* Notifications about new chapters with updates feed
|
||||
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
||||
* Password/fingerprint protect access to the app
|
||||
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices
|
||||
* Password/fingerprint-protected access to the app
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -53,5 +52,5 @@ install instructions.
|
||||
|
||||
### DMCA disclaimer
|
||||
|
||||
The developers of this application does not have any affiliation with the content available in the app.
|
||||
It is collecting from the sources freely available through any web browser.
|
||||
The developers of this application do not have any affiliation with the content available in the app.
|
||||
It collects content from sources that are freely available through any web browser
|
||||
|
||||
@@ -16,8 +16,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 35
|
||||
versionCode = 666
|
||||
versionName = '7.5'
|
||||
versionCode = 674
|
||||
versionName = '7.6.1'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
@@ -48,11 +48,11 @@ android {
|
||||
}
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
freeCompilerArgs += [
|
||||
'-opt-in=kotlin.ExperimentalStdlibApi',
|
||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||
@@ -83,23 +83,23 @@ afterEvaluate {
|
||||
}
|
||||
dependencies {
|
||||
//noinspection GradleDependency
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:b404b44008') {
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:1.1') {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.1'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.10'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0-RC.2'
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.20'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
implementation 'androidx.activity:activity-ktx:1.9.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.8.2'
|
||||
implementation 'androidx.activity:activity-ktx:1.9.2'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.8.3'
|
||||
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.collection:collection-ktx:1.4.4'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.6'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.6'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||
@@ -107,7 +107,7 @@ 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.4'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.6'
|
||||
implementation 'androidx.webkit:webkit:1.11.0'
|
||||
|
||||
implementation 'androidx.work:work-runtime:2.9.1'
|
||||
@@ -125,7 +125,7 @@ dependencies {
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp-tls:4.12.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
|
||||
implementation 'com.squareup.okio:okio:3.9.0'
|
||||
implementation 'com.squareup.okio:okio:3.9.1'
|
||||
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||
@@ -137,28 +137,28 @@ dependencies {
|
||||
|
||||
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:4ec7176962'
|
||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:b2c5a6d5ca'
|
||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||
implementation 'io.noties.markwon:core:4.6.2'
|
||||
|
||||
implementation 'ch.acra:acra-http:5.11.3'
|
||||
implementation 'ch.acra:acra-dialog:5.11.3'
|
||||
implementation 'ch.acra:acra-http:5.11.4'
|
||||
implementation 'ch.acra:acra-dialog:5.11.4'
|
||||
|
||||
implementation 'org.conscrypt:conscrypt-android:2.5.3'
|
||||
implementation 'org.conscrypt:conscrypt-android:2.5.2'
|
||||
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:3.0-alpha-8'
|
||||
debugImplementation 'com.github.Koitharu:WorkInspector:5778dd1747'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.json:json:20240303'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
|
||||
|
||||
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'
|
||||
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
|
||||
|
||||
androidTestImplementation 'androidx.room:room-testing:2.6.1'
|
||||
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
|
||||
|
||||
4
app/proguard-rules.pro
vendored
4
app/proguard-rules.pro
vendored
@@ -22,3 +22,7 @@
|
||||
-keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }
|
||||
-keep class org.jsoup.parser.Tag
|
||||
-keep class org.jsoup.internal.StringUtil
|
||||
|
||||
-keep class org.acra.security.NoKeyStoreFactory { *; }
|
||||
-keep class org.acra.config.DefaultRetryPolicy { *; }
|
||||
-keep class org.acra.attachment.DefaultAttachmentProvider { *; }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,18 +14,21 @@ 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.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val MAX_PARALLELISM = 4
|
||||
private const val MATCH_THRESHOLD = 0.2f
|
||||
private const val MATCH_THRESHOLD_DEFAULT = 0.2f
|
||||
|
||||
class AlternativesUseCase @Inject constructor(
|
||||
private val sourcesRepository: MangaSourcesRepository,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(manga: Manga): Flow<Manga> {
|
||||
suspend operator fun invoke(manga: Manga): Flow<Manga> = invoke(manga, MATCH_THRESHOLD_DEFAULT)
|
||||
|
||||
suspend operator fun invoke(manga: Manga, matchThreshold: Float): Flow<Manga> {
|
||||
val sources = getSources(manga.source)
|
||||
if (sources.isEmpty()) {
|
||||
return emptyFlow()
|
||||
@@ -34,17 +37,17 @@ class AlternativesUseCase @Inject constructor(
|
||||
return channelFlow {
|
||||
for (source in sources) {
|
||||
val repository = mangaRepositoryFactory.create(source)
|
||||
if (!repository.isSearchSupported) {
|
||||
if (!repository.filterCapabilities.isSearchSupported) {
|
||||
continue
|
||||
}
|
||||
launch {
|
||||
val list = runCatchingCancellable {
|
||||
semaphore.withPermit {
|
||||
repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
|
||||
repository.getList(offset = 0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title))
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
for (item in list) {
|
||||
if (item.matches(manga)) {
|
||||
if (item.matches(manga, matchThreshold)) {
|
||||
send(item)
|
||||
}
|
||||
}
|
||||
@@ -65,16 +68,16 @@ class AlternativesUseCase @Inject constructor(
|
||||
return result
|
||||
}
|
||||
|
||||
private fun Manga.matches(ref: Manga): Boolean {
|
||||
return matchesTitles(title, ref.title) ||
|
||||
matchesTitles(title, ref.altTitle) ||
|
||||
matchesTitles(altTitle, ref.title) ||
|
||||
matchesTitles(altTitle, ref.altTitle)
|
||||
private fun Manga.matches(ref: Manga, threshold: Float): Boolean {
|
||||
return matchesTitles(title, ref.title, threshold) ||
|
||||
matchesTitles(title, ref.altTitle, threshold) ||
|
||||
matchesTitles(altTitle, ref.title, threshold) ||
|
||||
matchesTitles(altTitle, ref.altTitle, threshold)
|
||||
|
||||
}
|
||||
|
||||
private fun matchesTitles(a: String?, b: String?): Boolean {
|
||||
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, MATCH_THRESHOLD)
|
||||
private fun matchesTitles(a: String?, b: String?, threshold: Float): Boolean {
|
||||
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold)
|
||||
}
|
||||
|
||||
private fun MangaSource.priority(ref: MangaSource): Int {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package org.koitharu.kotatsu.alternatives.domain
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.lastOrNull
|
||||
import kotlinx.coroutines.flow.runningFold
|
||||
import kotlinx.coroutines.flow.transformWhile
|
||||
import kotlinx.coroutines.flow.withIndex
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class AutoFixUseCase @Inject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val alternativesUseCase: AlternativesUseCase,
|
||||
private val migrateUseCase: MigrateUseCase,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(mangaId: Long): Pair<Manga, Manga?> {
|
||||
val seed = checkNotNull(mangaDataRepository.findMangaById(mangaId)) { "Manga $mangaId not found" }
|
||||
.getDetailsSafe()
|
||||
if (seed.isHealthy()) {
|
||||
return seed to null // no fix required
|
||||
}
|
||||
val replacement = alternativesUseCase(seed, matchThreshold = 0.02f)
|
||||
.filter { it.isHealthy() }
|
||||
.runningFold<Manga, Manga?>(null) { best, candidate ->
|
||||
if (best == null || best < candidate) {
|
||||
candidate
|
||||
} else {
|
||||
best
|
||||
}
|
||||
}.selectLastWithTimeout(4, 40, TimeUnit.SECONDS)
|
||||
migrateUseCase(seed, replacement ?: throw NoAlternativesException(ParcelableManga(seed)))
|
||||
return seed to replacement
|
||||
}
|
||||
|
||||
private suspend fun Manga.isHealthy(): Boolean = runCatchingCancellable {
|
||||
val repo = mangaRepositoryFactory.create(source)
|
||||
val details = if (this.chapters != null) this else repo.getDetails(this)
|
||||
val firstChapter = details.chapters?.firstOrNull() ?: return@runCatchingCancellable false
|
||||
val pageUrl = repo.getPageUrl(repo.getPages(firstChapter).first())
|
||||
pageUrl.toHttpUrlOrNull() != null
|
||||
}.getOrDefault(false)
|
||||
|
||||
private suspend fun Manga.getDetailsSafe() = runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(source).getDetails(this)
|
||||
}.getOrDefault(this)
|
||||
|
||||
private operator fun Manga.compareTo(other: Manga) = chaptersCount().compareTo(other.chaptersCount())
|
||||
|
||||
@Suppress("UNCHECKED_CAST", "OPT_IN_USAGE")
|
||||
private suspend fun <T> Flow<T>.selectLastWithTimeout(
|
||||
minCount: Int,
|
||||
timeout: Long,
|
||||
timeUnit: TimeUnit
|
||||
): T? = channelFlow<T?> {
|
||||
var lastValue: T? = null
|
||||
launch {
|
||||
delay(timeUnit.toMillis(timeout))
|
||||
close(InternalTimeoutException(lastValue))
|
||||
}
|
||||
withIndex().transformWhile { (index, value) ->
|
||||
lastValue = value
|
||||
emit(value)
|
||||
index < minCount && !isClosedForSend
|
||||
}.collect {
|
||||
send(it)
|
||||
}
|
||||
}.catch { e ->
|
||||
if (e is InternalTimeoutException) {
|
||||
emit(e.value as T?)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}.lastOrNull()
|
||||
|
||||
class NoAlternativesException(val seed: ParcelableManga) : NoSuchElementException()
|
||||
|
||||
private class InternalTimeoutException(val value: Any?) : CancellationException()
|
||||
}
|
||||
@@ -136,7 +136,7 @@ constructor(
|
||||
return HistoryEntity(
|
||||
mangaId = newManga.id,
|
||||
createdAt = history.createdAt,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
updatedAt = history.updatedAt,
|
||||
chapterId = currentChapter.id,
|
||||
page = history.page,
|
||||
scroll = history.scroll,
|
||||
@@ -173,7 +173,7 @@ constructor(
|
||||
return HistoryEntity(
|
||||
mangaId = newManga.id,
|
||||
createdAt = history.createdAt,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
updatedAt = history.updatedAt,
|
||||
chapterId = newChapterId,
|
||||
page = history.page,
|
||||
scroll = history.scroll,
|
||||
|
||||
@@ -30,7 +30,8 @@ import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -81,7 +82,14 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||
|
||||
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
||||
when (view.id) {
|
||||
R.id.chip_source -> startActivity(SearchActivity.newIntent(this, item.manga.source, viewModel.manga.title))
|
||||
R.id.chip_source -> startActivity(
|
||||
MangaListActivity.newIntent(
|
||||
this,
|
||||
item.manga.source,
|
||||
MangaListFilter(query = viewModel.manga.title),
|
||||
),
|
||||
)
|
||||
|
||||
R.id.button_migrate -> confirmMigration(item.manga)
|
||||
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
|
||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AutoFixService : CoroutineIntentService() {
|
||||
|
||||
@Inject
|
||||
lateinit var autoFixUseCase: AutoFixUseCase
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private lateinit var notificationManager: NotificationManagerCompat
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||
}
|
||||
|
||||
override suspend fun processIntent(startId: Int, intent: Intent) {
|
||||
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
|
||||
startForeground(startId)
|
||||
try {
|
||||
for (mangaId in ids) {
|
||||
val result = runCatchingCancellable {
|
||||
autoFixUseCase.invoke(mangaId)
|
||||
}
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = buildNotification(result)
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(startId: Int, error: Throwable) {
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = runBlocking { buildNotification(Result.failure(error)) }
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
private fun startForeground(startId: Int) {
|
||||
val title = applicationContext.getString(R.string.fixing_manga)
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
|
||||
.setName(title)
|
||||
.setShowBadge(false)
|
||||
.setVibrationEnabled(false)
|
||||
.setSound(null, null)
|
||||
.setLightsEnabled(false)
|
||||
.build()
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setDefaults(0)
|
||||
.setSilent(true)
|
||||
.setOngoing(true)
|
||||
.setProgress(0, 0, true)
|
||||
.setSmallIcon(R.drawable.ic_stat_auto_fix)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.addAction(
|
||||
materialR.drawable.material_ic_clear_black_24dp,
|
||||
applicationContext.getString(android.R.string.cancel),
|
||||
getCancelIntent(startId),
|
||||
)
|
||||
.build()
|
||||
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
FOREGROUND_NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun buildNotification(result: Result<Pair<Manga, Manga?>>): Notification {
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setDefaults(0)
|
||||
.setSilent(true)
|
||||
.setAutoCancel(true)
|
||||
result.onSuccess { (seed, replacement) ->
|
||||
if (replacement != null) {
|
||||
notification.setLargeIcon(
|
||||
coil.execute(
|
||||
ImageRequest.Builder(applicationContext)
|
||||
.data(replacement.coverUrl)
|
||||
.tag(replacement.source)
|
||||
.build(),
|
||||
).toBitmapOrNull(),
|
||||
)
|
||||
notification.setSubText(replacement.title)
|
||||
val intent = DetailsActivity.newIntent(applicationContext, replacement)
|
||||
notification.setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
applicationContext,
|
||||
replacement.id.toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
false,
|
||||
),
|
||||
).setVisibility(
|
||||
if (replacement.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC,
|
||||
)
|
||||
notification
|
||||
.setContentTitle(applicationContext.getString(R.string.fixed))
|
||||
.setContentText(
|
||||
applicationContext.getString(
|
||||
R.string.manga_replaced,
|
||||
seed.title,
|
||||
seed.source.getTitle(applicationContext),
|
||||
replacement.title,
|
||||
replacement.source.getTitle(applicationContext),
|
||||
),
|
||||
)
|
||||
.setSmallIcon(R.drawable.ic_stat_done)
|
||||
} else {
|
||||
notification
|
||||
.setContentTitle(applicationContext.getString(R.string.fixing_manga))
|
||||
.setContentText(applicationContext.getString(R.string.no_fix_required, seed.title))
|
||||
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
notification
|
||||
.setContentTitle(applicationContext.getString(R.string.error_occurred))
|
||||
.setContentText(
|
||||
if (error is AutoFixUseCase.NoAlternativesException) {
|
||||
applicationContext.getString(R.string.no_alternatives_found, error.seed.manga.title)
|
||||
} else {
|
||||
error.getDisplayMessage(applicationContext.resources)
|
||||
},
|
||||
)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.addAction(
|
||||
R.drawable.ic_alert_outline,
|
||||
applicationContext.getString(R.string.report),
|
||||
ErrorReporterReceiver.getPendingIntent(applicationContext, error),
|
||||
)
|
||||
}
|
||||
return notification.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val DATA_IDS = "ids"
|
||||
private const val TAG = "auto_fix"
|
||||
private const val CHANNEL_ID = "auto_fix"
|
||||
private const val FOREGROUND_NOTIFICATION_ID = 38
|
||||
|
||||
fun start(context: Context, mangaIds: Collection<Long>): Boolean = try {
|
||||
val intent = Intent(context, AutoFixService::class.java)
|
||||
intent.putExtra(DATA_IDS, mangaIds.toLongArray())
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
e.printStackTraceDebug()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.bookmarks.ui
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -129,7 +130,11 @@ class AllBookmarksFragment :
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
|
||||
return selectionController?.onItemLongClick(item.pageId) ?: false
|
||||
return selectionController?.onItemLongClick(view, item.pageId) ?: false
|
||||
}
|
||||
|
||||
override fun onItemContextClick(item: Bookmark, view: View): Boolean {
|
||||
return selectionController?.onItemContextClick(view, item.pageId) ?: false
|
||||
}
|
||||
|
||||
override fun onRetryClick(error: Throwable) = Unit
|
||||
@@ -148,23 +153,23 @@ class AllBookmarksFragment :
|
||||
|
||||
override fun onCreateActionMode(
|
||||
controller: ListSelectionController,
|
||||
mode: ActionMode,
|
||||
menuInflater: MenuInflater,
|
||||
menu: Menu,
|
||||
): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
|
||||
menuInflater.inflate(R.menu.mode_bookmarks, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(
|
||||
controller: ListSelectionController,
|
||||
mode: ActionMode,
|
||||
mode: ActionMode?,
|
||||
item: MenuItem,
|
||||
): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_remove -> {
|
||||
val ids = selectionController?.snapshot() ?: return false
|
||||
viewModel.removeBookmarks(ids)
|
||||
mode.finish()
|
||||
mode?.finish()
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -22,10 +22,7 @@ fun bookmarkLargeAD(
|
||||
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
|
||||
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||
|
||||
binding.root.setOnClickListener(listener)
|
||||
binding.root.setOnLongClickListener(listener)
|
||||
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
||||
|
||||
bind {
|
||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
||||
|
||||
@@ -21,10 +21,7 @@ fun bookmarkListAD(
|
||||
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
|
||||
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||
|
||||
binding.root.setOnClickListener(listener)
|
||||
binding.root.setOnLongClickListener(listener)
|
||||
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
||||
|
||||
bind {
|
||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.acra.ACRA
|
||||
@@ -28,6 +29,9 @@ import org.koitharu.kotatsu.core.os.AppValidator
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
||||
import java.security.Security
|
||||
import javax.inject.Inject
|
||||
@@ -60,6 +64,13 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
@Inject
|
||||
lateinit var workManagerProvider: Provider<WorkManager>
|
||||
|
||||
@Inject
|
||||
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>
|
||||
|
||||
@Inject
|
||||
@LocalStorageChanges
|
||||
lateinit var localStorageChanges: MutableSharedFlow<LocalManga?>
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
@@ -82,6 +93,7 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
}
|
||||
processLifecycleScope.launch(Dispatchers.Default) {
|
||||
setupDatabaseObservers()
|
||||
localStorageChanges.collect(localMangaIndexProvider.get())
|
||||
}
|
||||
workScheduleManager.init()
|
||||
WorkServiceStopHelper(workManagerProvider).setup()
|
||||
|
||||
@@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.Closeable
|
||||
import org.json.JSONArray
|
||||
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
|
||||
@@ -38,7 +39,7 @@ class BackupZipInput private constructor(val file: File) : Closeable {
|
||||
fun cleanupAsync() {
|
||||
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
|
||||
runCatching {
|
||||
close()
|
||||
closeQuietly()
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
@@ -46,14 +47,22 @@ class BackupZipInput private constructor(val file: File) : Closeable {
|
||||
|
||||
companion object {
|
||||
|
||||
fun from(file: File): BackupZipInput = try {
|
||||
val res = BackupZipInput(file)
|
||||
if (res.zipFile.getEntry("index") == null) {
|
||||
throw BadBackupFormatException(null)
|
||||
fun from(file: File): BackupZipInput {
|
||||
var res: BackupZipInput? = null
|
||||
return try {
|
||||
res = BackupZipInput(file)
|
||||
if (res.zipFile.getEntry("index") == null) {
|
||||
throw BadBackupFormatException(null)
|
||||
}
|
||||
res
|
||||
} catch (exception: Exception) {
|
||||
res?.closeQuietly()
|
||||
throw if (exception is ZipException) {
|
||||
BadBackupFormatException(exception)
|
||||
} else {
|
||||
exception
|
||||
}
|
||||
}
|
||||
res
|
||||
} catch (e: ZipException) {
|
||||
throw BadBackupFormatException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ 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.Migration22To23
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
||||
@@ -50,6 +51,8 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||
import org.koitharu.kotatsu.favourites.data.FavouritesDao
|
||||
import org.koitharu.kotatsu.history.data.HistoryDao
|
||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndexDao
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndexEntity
|
||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
|
||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
||||
import org.koitharu.kotatsu.stats.data.StatsDao
|
||||
@@ -60,14 +63,14 @@ 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 = 22
|
||||
const val DATABASE_VERSION = 23
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
||||
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
|
||||
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
|
||||
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class,
|
||||
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class,
|
||||
],
|
||||
version = DATABASE_VERSION,
|
||||
)
|
||||
@@ -98,6 +101,8 @@ abstract class MangaDatabase : RoomDatabase() {
|
||||
abstract fun getSourcesDao(): MangaSourcesDao
|
||||
|
||||
abstract fun getStatsDao(): StatsDao
|
||||
|
||||
abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao
|
||||
}
|
||||
|
||||
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||
@@ -122,6 +127,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||
Migration19To20(),
|
||||
Migration20To21(),
|
||||
Migration21To22(),
|
||||
Migration22To23(),
|
||||
)
|
||||
|
||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.koitharu.kotatsu.core.db.dao
|
||||
|
||||
import androidx.room.*
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
||||
|
||||
@@ -13,6 +15,9 @@ abstract class PreferencesDao {
|
||||
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
|
||||
abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?>
|
||||
|
||||
@Query("UPDATE preferences SET cf_brightness = 0, cf_contrast = 0, cf_invert = 0, cf_grayscale = 0")
|
||||
abstract suspend fun resetColorFilters()
|
||||
|
||||
@Upsert
|
||||
abstract suspend fun upsert(pref: MangaPrefsEntity)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
|
||||
|
||||
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
|
||||
|
||||
fun Collection<MangaWithTags>.toMangaList() = map { it.toManga() }
|
||||
|
||||
// Model to entity
|
||||
|
||||
fun Manga.toEntity() = MangaEntity(
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration22To23 : Migration(22, 23) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `local_index` (`manga_id` INTEGER NOT NULL, `path` TEXT NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.logs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.core.util.ext.subdir
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
private const val DIR = "logs"
|
||||
private const val FLUSH_DELAY = 2_000L
|
||||
private const val MAX_SIZE_BYTES = 1024 * 1024 // 1 MB
|
||||
|
||||
class FileLogger(
|
||||
context: Context,
|
||||
private val settings: AppSettings,
|
||||
name: String,
|
||||
) {
|
||||
|
||||
val file by lazy {
|
||||
val dir = context.getExternalFilesDir(DIR) ?: context.filesDir.subdir(DIR)
|
||||
File(dir, "$name.log")
|
||||
}
|
||||
val isEnabled: Boolean
|
||||
get() = settings.isLoggingEnabled
|
||||
private val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withLocale(Locale.ROOT)
|
||||
private val buffer = ConcurrentLinkedQueue<String>()
|
||||
private val mutex = Mutex()
|
||||
private var flushJob: Job? = null
|
||||
|
||||
fun log(message: String, e: Throwable? = null) {
|
||||
if (!isEnabled) {
|
||||
return
|
||||
}
|
||||
val text = buildString {
|
||||
append(dateTimeFormatter.format(LocalDateTime.now()))
|
||||
append(": ")
|
||||
if (e != null) {
|
||||
append("E!")
|
||||
}
|
||||
append(message)
|
||||
if (e != null) {
|
||||
append(' ')
|
||||
append(e.stackTraceToString())
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
buffer.add(text)
|
||||
postFlush()
|
||||
}
|
||||
|
||||
inline fun log(messageProducer: () -> String) {
|
||||
if (isEnabled) {
|
||||
log(messageProducer())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun flush() {
|
||||
if (!isEnabled) {
|
||||
return
|
||||
}
|
||||
flushJob?.cancelAndJoin()
|
||||
flushImpl()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun flushBlocking() {
|
||||
if (!isEnabled) {
|
||||
return
|
||||
}
|
||||
runBlockingSafe { flushJob?.cancelAndJoin() }
|
||||
runBlockingSafe { flushImpl() }
|
||||
}
|
||||
|
||||
private fun postFlush() {
|
||||
if (flushJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
flushJob = processLifecycleScope.launch(Dispatchers.Default) {
|
||||
delay(FLUSH_DELAY)
|
||||
runCatchingCancellable {
|
||||
flushImpl()
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun flushImpl() = withContext(NonCancellable) {
|
||||
mutex.withLock {
|
||||
if (buffer.isEmpty()) {
|
||||
return@withContext
|
||||
}
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
if (file.length() > MAX_SIZE_BYTES) {
|
||||
rotate()
|
||||
}
|
||||
FileOutputStream(file, true).use {
|
||||
while (true) {
|
||||
val message = buffer.poll() ?: break
|
||||
it.write(message.toByteArray())
|
||||
it.write('\n'.code)
|
||||
}
|
||||
it.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun rotate() {
|
||||
val length = file.length()
|
||||
val bakFile = File(file.parentFile, file.name + ".bak")
|
||||
file.renameTo(bakFile)
|
||||
bakFile.inputStream().use { input ->
|
||||
input.skip(length - MAX_SIZE_BYTES / 2)
|
||||
file.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
bakFile.delete()
|
||||
}
|
||||
|
||||
private inline fun runBlockingSafe(crossinline block: suspend () -> Unit) = try {
|
||||
runBlocking(NonCancellable) { block() }
|
||||
} catch (_: InterruptedException) {
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.logs
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class TrackerLogger
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class SyncLogger
|
||||
@@ -1,40 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.logs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.collection.arraySetOf
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.ElementsIntoSet
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object LoggersModule {
|
||||
|
||||
@Provides
|
||||
@TrackerLogger
|
||||
fun provideTrackerLogger(
|
||||
@ApplicationContext context: Context,
|
||||
settings: AppSettings,
|
||||
) = FileLogger(context, settings, "tracker")
|
||||
|
||||
@Provides
|
||||
@SyncLogger
|
||||
fun provideSyncLogger(
|
||||
@ApplicationContext context: Context,
|
||||
settings: AppSettings,
|
||||
) = FileLogger(context, settings, "sync")
|
||||
|
||||
@Provides
|
||||
@ElementsIntoSet
|
||||
fun provideAllLoggers(
|
||||
@TrackerLogger trackerLogger: FileLogger,
|
||||
@SyncLogger syncLogger: FileLogger,
|
||||
): Set<@JvmSuppressWildcards FileLogger> = arraySetOf(
|
||||
trackerLogger,
|
||||
syncLogger,
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
|
||||
@Deprecated("")
|
||||
enum class GenericSortOrder(
|
||||
@StringRes val titleResId: Int,
|
||||
val ascending: SortOrder,
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
import android.net.Uri
|
||||
import android.text.SpannableStringBuilder
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.collection.MutableObjectIntMap
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.strikeThrough
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.iterator
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.Demographic
|
||||
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.MangaState
|
||||
import org.koitharu.kotatsu.parsers.util.formatSimple
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
@@ -68,6 +73,17 @@ val ContentRating.titleResId: Int
|
||||
ContentRating.ADULT -> R.string.rating_adult
|
||||
}
|
||||
|
||||
@get:StringRes
|
||||
val Demographic.titleResId: Int
|
||||
get() = when (this) {
|
||||
Demographic.SHOUNEN -> R.string.demographic_shounen
|
||||
Demographic.SHOUJO -> R.string.demographic_shoujo
|
||||
Demographic.SEINEN -> R.string.demographic_seinen
|
||||
Demographic.JOSEI -> R.string.demographic_josei
|
||||
Demographic.KODOMO -> R.string.demographic_kodomo
|
||||
Demographic.NONE -> R.string.none
|
||||
}
|
||||
|
||||
fun Manga.findChapter(id: Long): MangaChapter? {
|
||||
return chapters?.findById(id)
|
||||
}
|
||||
@@ -110,6 +126,9 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
|
||||
val Manga.isLocal: Boolean
|
||||
get() = source == LocalMangaSource
|
||||
|
||||
val Manga.isBroken: Boolean
|
||||
get() = source == UnknownMangaSource
|
||||
|
||||
val Manga.appUrl: Uri
|
||||
get() = Uri.parse("https://kotatsu.app/manga").buildUpon()
|
||||
.appendQueryParameter("source", source.name)
|
||||
@@ -138,3 +157,26 @@ fun Manga.chaptersCount(): Int {
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
fun MangaListFilter.getSummary() = buildSpannedString {
|
||||
if (!query.isNullOrEmpty()) {
|
||||
append(query)
|
||||
if (tags.isNotEmpty() || tagsExclude.isNotEmpty()) {
|
||||
append(' ')
|
||||
append('(')
|
||||
appendTagsSummary(this@getSummary)
|
||||
append(')')
|
||||
}
|
||||
} else {
|
||||
appendTagsSummary(this@getSummary)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SpannableStringBuilder.appendTagsSummary(filter: MangaListFilter) {
|
||||
filter.tags.joinTo(this) { it.title }
|
||||
if (filter.tagsExclude.isNotEmpty()) {
|
||||
strikeThrough {
|
||||
filter.tagsExclude.joinTo(this) { it.title }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ data class MangaHistory(
|
||||
val page: Int,
|
||||
val scroll: Int,
|
||||
val percent: Float,
|
||||
val chaptersCount: Int,
|
||||
) : Parcelable
|
||||
|
||||
@@ -43,6 +43,8 @@ fun MangaSource(name: String?): MangaSource {
|
||||
return UnknownMangaSource
|
||||
}
|
||||
|
||||
fun Collection<String>.toMangaSources() = map(::MangaSource)
|
||||
|
||||
fun MangaSource.isNsfw(): Boolean = when (this) {
|
||||
is MangaSourceInfo -> mangaSource.isNsfw()
|
||||
is MangaParserSource -> contentType == ContentType.HENTAI
|
||||
@@ -56,13 +58,26 @@ val ContentType.titleResId
|
||||
ContentType.HENTAI -> R.string.content_type_hentai
|
||||
ContentType.COMICS -> R.string.content_type_comics
|
||||
ContentType.OTHER -> R.string.content_type_other
|
||||
ContentType.MANHWA -> R.string.content_type_manhwa
|
||||
ContentType.MANHUA -> R.string.content_type_manhua
|
||||
ContentType.NOVEL -> R.string.content_type_novel
|
||||
ContentType.ONE_SHOT -> R.string.content_type_one_shot
|
||||
ContentType.DOUJINSHI -> R.string.content_type_doujinshi
|
||||
ContentType.IMAGE_SET -> R.string.content_type_image_set
|
||||
ContentType.ARTIST_CG -> R.string.content_type_artist_cg
|
||||
ContentType.GAME_CG -> R.string.content_type_game_cg
|
||||
}
|
||||
|
||||
fun MangaSource.getSummary(context: Context): String? = when (this) {
|
||||
is MangaSourceInfo -> mangaSource.getSummary(context)
|
||||
tailrec fun MangaSource.unwrap(): MangaSource = if (this is MangaSourceInfo) {
|
||||
mangaSource.unwrap()
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
fun MangaSource.getSummary(context: Context): String? = when (val source = unwrap()) {
|
||||
is MangaParserSource -> {
|
||||
val type = context.getString(contentType.titleResId)
|
||||
val locale = locale.toLocale().getDisplayName(context)
|
||||
val type = context.getString(source.contentType.titleResId)
|
||||
val locale = source.locale.toLocale().getDisplayName(context)
|
||||
context.getString(R.string.source_summary_pattern, type, locale)
|
||||
}
|
||||
|
||||
@@ -71,11 +86,10 @@ fun MangaSource.getSummary(context: Context): String? = when (this) {
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun MangaSource.getTitle(context: Context): String = when (this) {
|
||||
is MangaSourceInfo -> mangaSource.getTitle(context)
|
||||
is MangaParserSource -> title
|
||||
fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()) {
|
||||
is MangaParserSource -> source.title
|
||||
LocalMangaSource -> context.getString(R.string.local_storage)
|
||||
is ExternalMangaSource -> resolveName(context)
|
||||
is ExternalMangaSource -> source.resolveName(context)
|
||||
else -> context.getString(R.string.unknown)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.koitharu.kotatsu.core.model.parcelable
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parceler
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.parcelize.TypeParceler
|
||||
import org.koitharu.kotatsu.core.util.ext.readEnumSet
|
||||
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.writeEnumSet
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.Demographic
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
|
||||
object MangaListFilterParceler : Parceler<MangaListFilter> {
|
||||
|
||||
override fun MangaListFilter.write(parcel: Parcel, flags: Int) {
|
||||
parcel.writeString(query)
|
||||
parcel.writeParcelable(ParcelableMangaTags(tags), 0)
|
||||
parcel.writeParcelable(ParcelableMangaTags(tagsExclude), 0)
|
||||
parcel.writeSerializable(locale)
|
||||
parcel.writeSerializable(originalLocale)
|
||||
parcel.writeEnumSet(states)
|
||||
parcel.writeEnumSet(contentRating)
|
||||
parcel.writeEnumSet(types)
|
||||
parcel.writeEnumSet(demographics)
|
||||
parcel.writeInt(year)
|
||||
parcel.writeInt(yearFrom)
|
||||
parcel.writeInt(yearTo)
|
||||
}
|
||||
|
||||
override fun create(parcel: Parcel) = MangaListFilter(
|
||||
query = parcel.readString(),
|
||||
tags = parcel.readParcelableCompat<ParcelableMangaTags>()?.tags.orEmpty(),
|
||||
tagsExclude = parcel.readParcelableCompat<ParcelableMangaTags>()?.tags.orEmpty(),
|
||||
locale = parcel.readSerializableCompat(),
|
||||
originalLocale = parcel.readSerializableCompat(),
|
||||
states = parcel.readEnumSet<MangaState>().orEmpty(),
|
||||
contentRating = parcel.readEnumSet<ContentRating>().orEmpty(),
|
||||
types = parcel.readEnumSet<ContentType>().orEmpty(),
|
||||
demographics = parcel.readEnumSet<Demographic>().orEmpty(),
|
||||
year = parcel.readInt(),
|
||||
yearFrom = parcel.readInt(),
|
||||
yearTo = parcel.readInt(),
|
||||
)
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@TypeParceler<MangaListFilter, MangaListFilterParceler>
|
||||
data class ParcelableMangaListFilter(val filter: MangaListFilter) : Parcelable
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AndroidRuntimeException
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -19,6 +18,7 @@ import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.RealImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Provider
|
||||
@@ -40,9 +40,10 @@ interface NetworkModule {
|
||||
@Singleton
|
||||
fun provideCookieJar(
|
||||
@ApplicationContext context: Context
|
||||
): MutableCookieJar = try {
|
||||
): MutableCookieJar = runCatching {
|
||||
AndroidCookieJar()
|
||||
} catch (e: AndroidRuntimeException) {
|
||||
}.getOrElse { e ->
|
||||
e.printStackTraceDebug()
|
||||
// WebView is not available
|
||||
PreferencesCookieJar(context)
|
||||
}
|
||||
@@ -73,7 +74,7 @@ interface NetworkModule {
|
||||
if (settings.isSSLBypassEnabled) {
|
||||
disableCertificateVerification()
|
||||
} else {
|
||||
installExtraCertsificates(contextProvider.get())
|
||||
installExtraCertificates(contextProvider.get())
|
||||
}
|
||||
cache(cache)
|
||||
addInterceptor(GZipInterceptor())
|
||||
|
||||
@@ -35,7 +35,7 @@ fun OkHttpClient.Builder.disableCertificateVerification() = also { builder ->
|
||||
}
|
||||
}
|
||||
|
||||
fun OkHttpClient.Builder.installExtraCertsificates(context: Context) = also { builder ->
|
||||
fun OkHttpClient.Builder.installExtraCertificates(context: Context) = also { builder ->
|
||||
val certificatesBuilder = HandshakeCertificates.Builder()
|
||||
.addPlatformTrustedCertificates()
|
||||
val assets = context.assets.list("").orEmpty()
|
||||
|
||||
@@ -180,7 +180,7 @@ class AppShortcutManager @Inject constructor(
|
||||
.setLongLabel(title)
|
||||
.setIcon(icon)
|
||||
.setLongLived(true)
|
||||
.setIntent(MangaListActivity.newIntent(context, source))
|
||||
.setIntent(MangaListActivity.newIntent(context, source, null))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import android.graphics.Rect as AndroidRect
|
||||
|
||||
class BitmapWrapper private constructor(
|
||||
private val androidBitmap: AndroidBitmap,
|
||||
) : Bitmap {
|
||||
) : Bitmap, AutoCloseable {
|
||||
|
||||
private val canvas by lazy { Canvas(androidBitmap) } // is not always used, so initialized lazily
|
||||
|
||||
@@ -24,17 +24,21 @@ class BitmapWrapper private constructor(
|
||||
canvas.drawBitmap(androidSourceBitmap, src.toAndroidRect(), dst.toAndroidRect(), null)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
androidBitmap.recycle()
|
||||
}
|
||||
|
||||
fun compressTo(output: OutputStream) {
|
||||
androidBitmap.compress(AndroidBitmap.CompressFormat.PNG, 100, output)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(width: Int, height: Int): Bitmap = BitmapWrapper(
|
||||
fun create(width: Int, height: Int) = BitmapWrapper(
|
||||
AndroidBitmap.createBitmap(width, height, AndroidBitmap.Config.ARGB_8888),
|
||||
)
|
||||
|
||||
fun create(bitmap: AndroidBitmap): Bitmap = BitmapWrapper(
|
||||
fun create(bitmap: AndroidBitmap) = BitmapWrapper(
|
||||
if (bitmap.isMutable) bitmap else bitmap.copy(AndroidBitmap.Config.ARGB_8888, true),
|
||||
)
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
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.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
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
|
||||
|
||||
@@ -24,14 +25,17 @@ class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParse
|
||||
override val availableSortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
override val filterCapabilities: MangaListFilterCapabilities
|
||||
get() = MangaListFilterCapabilities()
|
||||
|
||||
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
|
||||
override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> = stub(null)
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
|
||||
|
||||
override suspend fun getAvailableTags(): Set<MangaTag> = stub(null)
|
||||
|
||||
private fun stub(manga: Manga?): Nothing {
|
||||
throw UnsupportedSourceException("Usage of Dummy parser", manga)
|
||||
}
|
||||
|
||||
@@ -1,37 +1,29 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
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.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
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
|
||||
|
||||
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
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 val filterCapabilities: MangaListFilterCapabilities
|
||||
get() = MangaListFilterCapabilities()
|
||||
|
||||
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> = stub(null)
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||
|
||||
@@ -39,9 +31,7 @@ class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage): String = stub(null)
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> = stub(null)
|
||||
|
||||
override suspend fun getLocales(): Set<Locale> = stub(null)
|
||||
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
|
||||
|
||||
override suspend fun getRelated(seed: Manga): List<Manga> = stub(seed)
|
||||
|
||||
|
||||
@@ -52,6 +52,10 @@ class MangaDataRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resetColorFilters() {
|
||||
db.getPreferencesDao().resetColorFilters()
|
||||
}
|
||||
|
||||
suspend fun getReaderMode(mangaId: Long): ReaderMode? {
|
||||
return db.getPreferencesDao().find(mangaId)?.let { ReaderMode.valueOf(it.mode) }
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ class MangaLinkResolver @Inject constructor(
|
||||
|
||||
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
|
||||
if (!title.isNullOrEmpty()) {
|
||||
val list = getList(0, MangaListFilter.Search(title))
|
||||
val list = getList(0, null, MangaListFilter(query = title))
|
||||
if (url != null) {
|
||||
list.find { it.url == url }?.let {
|
||||
return it
|
||||
@@ -80,7 +80,7 @@ class MangaLinkResolver @Inject constructor(
|
||||
}.ifNullOrEmpty {
|
||||
seed.author
|
||||
} ?: return@runCatchingCancellable null
|
||||
val seedList = getList(0, MangaListFilter.Search(seedTitle))
|
||||
val seedList = getList(0, null, MangaListFilter(query = seedTitle))
|
||||
seedList.first { x -> x.url == url }
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
@@ -21,14 +21,15 @@ import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.requireBody
|
||||
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
|
||||
import org.koitharu.kotatsu.core.util.ext.toList
|
||||
import org.koitharu.kotatsu.core.util.ext.use
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
|
||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import org.koitharu.kotatsu.parsers.util.map
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
@@ -76,32 +77,25 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override fun redrawImageResponse(response: Response, redraw: (image: Bitmap) -> Bitmap): Response {
|
||||
val image = response.requireBody().byteStream()
|
||||
|
||||
val opts = BitmapFactory.Options()
|
||||
opts.inMutable = true
|
||||
val bitmap = BitmapFactory.decodeStream(image, null, opts) ?: error("Cannot decode bitmap")
|
||||
val result = redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper
|
||||
|
||||
val body = Buffer().also {
|
||||
result.compressTo(it.outputStream())
|
||||
}.asResponseBody("image/jpeg".toMediaType())
|
||||
|
||||
return response.newBuilder()
|
||||
.body(body)
|
||||
.build()
|
||||
return response.map { body ->
|
||||
val opts = BitmapFactory.Options()
|
||||
opts.inMutable = true
|
||||
BitmapFactory.decodeStream(body.byteStream(), null, opts)?.use { bitmap ->
|
||||
(redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper).use { result ->
|
||||
Buffer().also {
|
||||
result.compressTo(it.outputStream())
|
||||
}.asResponseBody("image/jpeg".toMediaType())
|
||||
}
|
||||
} ?: error("Cannot decode bitmap")
|
||||
}
|
||||
}
|
||||
|
||||
override fun createBitmap(width: Int, height: Int): Bitmap {
|
||||
return BitmapWrapper.create(width, height)
|
||||
}
|
||||
override fun createBitmap(width: Int, height: Int): Bitmap = BitmapWrapper.create(width, height)
|
||||
|
||||
@MainThread
|
||||
private fun obtainWebView(): WebView {
|
||||
return webViewCached?.get() ?: WebView(androidContext).also {
|
||||
it.configureForParser(null)
|
||||
webViewCached = WeakReference(it)
|
||||
}
|
||||
private fun obtainWebView(): WebView = webViewCached?.get() ?: WebView(androidContext).also {
|
||||
it.configureForParser(null)
|
||||
webViewCached = WeakReference(it)
|
||||
}
|
||||
|
||||
private fun obtainWebViewUserAgent(): String {
|
||||
|
||||
@@ -13,18 +13,16 @@ 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
|
||||
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.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
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.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.collections.set
|
||||
@@ -35,19 +33,11 @@ interface MangaRepository {
|
||||
|
||||
val sortOrders: Set<SortOrder>
|
||||
|
||||
val states: Set<MangaState>
|
||||
|
||||
val contentRatings: Set<ContentRating>
|
||||
|
||||
var defaultSortOrder: SortOrder
|
||||
|
||||
val isMultipleTagsSupported: Boolean
|
||||
val filterCapabilities: MangaListFilterCapabilities
|
||||
|
||||
val isTagsExclusionSupported: Boolean
|
||||
|
||||
val isSearchSupported: Boolean
|
||||
|
||||
suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga>
|
||||
suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga>
|
||||
|
||||
suspend fun getDetails(manga: Manga): Manga
|
||||
|
||||
@@ -55,14 +45,12 @@ interface MangaRepository {
|
||||
|
||||
suspend fun getPageUrl(page: MangaPage): String
|
||||
|
||||
suspend fun getTags(): Set<MangaTag>
|
||||
|
||||
suspend fun getLocales(): Set<Locale>
|
||||
suspend fun getFilterOptions(): MangaListFilterOptions
|
||||
|
||||
suspend fun getRelated(seed: Manga): List<Manga>
|
||||
|
||||
suspend fun find(manga: Manga): Manga? {
|
||||
val list = getList(0, MangaListFilter.Search(manga.title))
|
||||
val list = getList(0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title))
|
||||
return list.find { x -> x.id == manga.id }
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,14 @@ import org.koitharu.kotatsu.parsers.model.Favicons
|
||||
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.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
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
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.parsers.util.domain
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.util.Locale
|
||||
@@ -28,17 +31,20 @@ class ParserMangaRepository(
|
||||
cache: MemoryContentCache,
|
||||
) : CachingMangaRepository(cache), Interceptor {
|
||||
|
||||
private val filterOptionsLazy = SuspendLazy {
|
||||
mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
parser.getFilterOptions()
|
||||
}
|
||||
}
|
||||
|
||||
override val source: MangaParserSource
|
||||
get() = parser.source
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = parser.availableSortOrders
|
||||
|
||||
override val states: Set<MangaState>
|
||||
get() = parser.availableStates
|
||||
|
||||
override val contentRatings: Set<ContentRating>
|
||||
get() = parser.availableContentRating
|
||||
override val filterCapabilities: MangaListFilterCapabilities
|
||||
get() = parser.filterCapabilities
|
||||
|
||||
override var defaultSortOrder: SortOrder
|
||||
get() = getConfig().defaultSortOrder ?: sortOrders.first()
|
||||
@@ -46,15 +52,6 @@ class ParserMangaRepository(
|
||||
getConfig().defaultSortOrder = value
|
||||
}
|
||||
|
||||
override val isMultipleTagsSupported: Boolean
|
||||
get() = parser.isMultipleTagsSupported
|
||||
|
||||
override val isSearchSupported: Boolean
|
||||
get() = parser.isSearchSupported
|
||||
|
||||
override val isTagsExclusionSupported: Boolean
|
||||
get() = parser.isTagsExclusionSupported
|
||||
|
||||
var domain: String
|
||||
get() = parser.domain
|
||||
set(value) {
|
||||
@@ -72,9 +69,9 @@ class ParserMangaRepository(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
|
||||
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> {
|
||||
return mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
parser.getList(offset, filter)
|
||||
parser.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,13 +85,7 @@ class ParserMangaRepository(
|
||||
parser.getPageUrl(page)
|
||||
}
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
parser.getAvailableTags()
|
||||
}
|
||||
|
||||
override suspend fun getLocales(): Set<Locale> {
|
||||
return parser.getAvailableLocales()
|
||||
}
|
||||
override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptionsLazy.get()
|
||||
|
||||
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
parser.getFavicons()
|
||||
|
||||
@@ -6,16 +6,15 @@ 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.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
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.SuspendLazy
|
||||
import java.util.EnumSet
|
||||
import java.util.Locale
|
||||
|
||||
class ExternalMangaRepository(
|
||||
private val contentResolver: ContentResolver,
|
||||
@@ -33,31 +32,23 @@ class ExternalMangaRepository(
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private val filterOptions = SuspendLazy(contentSource::getListFilterOptions)
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL)
|
||||
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.POPULARITY)
|
||||
|
||||
override val states: Set<MangaState>
|
||||
get() = capabilities?.availableStates.orEmpty()
|
||||
|
||||
override val contentRatings: Set<ContentRating>
|
||||
get() = capabilities?.availableContentRating.orEmpty()
|
||||
override val filterCapabilities: MangaListFilterCapabilities
|
||||
get() = capabilities?.listFilterCapabilities ?: MangaListFilterCapabilities()
|
||||
|
||||
override var defaultSortOrder: SortOrder
|
||||
get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL
|
||||
get() = capabilities?.availableSortOrders?.firstOrNull() ?: SortOrder.ALPHABETICAL
|
||||
set(value) = Unit
|
||||
|
||||
override val isMultipleTagsSupported: Boolean
|
||||
get() = capabilities?.isMultipleTagsSupported ?: true
|
||||
override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptions.get()
|
||||
|
||||
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> =
|
||||
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> =
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
contentSource.getList(offset, filter)
|
||||
contentSource.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY)
|
||||
}
|
||||
|
||||
override suspend fun getDetailsImpl(manga: Manga): Manga = runInterruptible(Dispatchers.IO) {
|
||||
@@ -68,13 +59,9 @@ class ExternalMangaRepository(
|
||||
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 getPageUrl(page: MangaPage): String = runInterruptible(Dispatchers.IO) {
|
||||
contentSource.getPageUrl(page.url)
|
||||
}
|
||||
|
||||
override suspend fun getLocales(): Set<Locale> = emptySet() // TODO
|
||||
|
||||
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@ 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.Demographic
|
||||
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.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
@@ -31,25 +33,29 @@ class ExternalPluginContentSource(
|
||||
|
||||
@Blocking
|
||||
@WorkerThread
|
||||
fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
|
||||
fun getListFilterOptions() = MangaListFilterOptions(
|
||||
availableTags = fetchTags(),
|
||||
availableStates = fetchEnumSet(MangaState::class.java, "filter/states"),
|
||||
availableContentRating = fetchEnumSet(ContentRating::class.java, "filter/content_ratings"),
|
||||
availableContentTypes = fetchEnumSet(ContentType::class.java, "filter/content_types"),
|
||||
availableDemographics = fetchEnumSet(Demographic::class.java, "filter/demographics"),
|
||||
availableLocales = fetchLocales(),
|
||||
)
|
||||
|
||||
@Blocking
|
||||
@WorkerThread
|
||||
fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
|
||||
val uri = "content://${source.authority}/manga".toUri().buildUpon()
|
||||
uri.appendQueryParameter("offset", offset.toString())
|
||||
when (filter) {
|
||||
is MangaListFilter.Advanced -> {
|
||||
filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") }
|
||||
filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") }
|
||||
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
|
||||
filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") }
|
||||
filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") }
|
||||
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) }
|
||||
if (!filter.query.isNullOrEmpty()) {
|
||||
uri.appendQueryParameter("query", filter.query)
|
||||
}
|
||||
return contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name)
|
||||
return contentResolver.query(uri.build(), null, null, null, order.name)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
val result = ArrayList<Manga>(cursor.count)
|
||||
@@ -113,8 +119,8 @@ class ExternalPluginContentSource(
|
||||
|
||||
@Blocking
|
||||
@WorkerThread
|
||||
fun getTags(): Set<MangaTag> {
|
||||
val uri = "content://${source.authority}/tags".toUri()
|
||||
private fun fetchTags(): Set<MangaTag> {
|
||||
val uri = "content://${source.authority}/filter/tags".toUri()
|
||||
return contentResolver.query(uri, null, null, null, null)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
@@ -132,6 +138,40 @@ class ExternalPluginContentSource(
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@WorkerThread
|
||||
fun getPageUrl(url: String): String {
|
||||
val uri = "content://${source.authority}/pages/0".toUri().buildUpon()
|
||||
.appendQueryParameter("url", url)
|
||||
.build()
|
||||
return contentResolver.query(uri, null, null, null, null)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getString(COLUMN_VALUE)
|
||||
} else {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@WorkerThread
|
||||
private fun fetchLocales(): Set<Locale> {
|
||||
val uri = "content://${source.authority}/filter/locales".toUri()
|
||||
return contentResolver.query(uri, null, null, null, null)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
val result = ArraySet<Locale>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += Locale(cursor.getString(COLUMN_NAME))
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fun getCapabilities(): MangaSourceCapabilities? {
|
||||
val uri = "content://${source.authority}/capabilities".toUri()
|
||||
return contentResolver.query(uri, null, null, null, null)
|
||||
@@ -144,26 +184,18 @@ class ExternalPluginContentSource(
|
||||
?.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,
|
||||
listFilterCapabilities = MangaListFilterCapabilities(
|
||||
isMultipleTagsSupported = cursor.getBooleanOrDefault(COLUMN_MULTIPLE_TAGS, false),
|
||||
isTagsExclusionSupported = cursor.getBooleanOrDefault(COLUMN_TAGS_EXCLUSION, false),
|
||||
isSearchSupported = cursor.getBooleanOrDefault(COLUMN_SEARCH, false),
|
||||
isSearchWithFiltersSupported = cursor.getBooleanOrDefault(
|
||||
COLUMN_SEARCH_WITH_FILTERS,
|
||||
false,
|
||||
),
|
||||
isYearSupported = cursor.getBooleanOrDefault(COLUMN_YEAR, false),
|
||||
isYearRangeSupported = cursor.getBooleanOrDefault(COLUMN_YEAR_RANGE, false),
|
||||
isOriginalLocaleSupported = cursor.getBooleanOrDefault(COLUMN_ORIGINAL_LOCALE, false),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -233,6 +265,26 @@ class ExternalPluginContentSource(
|
||||
source = source,
|
||||
)
|
||||
|
||||
private fun <E : Enum<E>> fetchEnumSet(cls: Class<E>, path: String): EnumSet<E> {
|
||||
val uri = "content://${source.authority}/$path".toUri()
|
||||
return contentResolver.query(uri, null, null, null, null)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
val result = EnumSet.noneOf(cls)
|
||||
val enumConstants = cls.enumConstants ?: return@use result
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
val name = cursor.getString(COLUMN_NAME)
|
||||
val enumValue = enumConstants.find { it.name == name }
|
||||
if (enumValue != null) {
|
||||
result.add(enumValue)
|
||||
}
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
private fun Cursor?.safe() = ExternalPluginCursor(
|
||||
source = source,
|
||||
cursor = this ?: throw IncompatiblePluginException(source.name, null),
|
||||
@@ -240,27 +292,19 @@ class ExternalPluginContentSource(
|
||||
|
||||
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,
|
||||
val listFilterCapabilities: MangaListFilterCapabilities,
|
||||
)
|
||||
|
||||
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_MULTIPLE_TAGS = "multiple_tags"
|
||||
const val COLUMN_TAGS_EXCLUSION = "tags_exclusion"
|
||||
const val COLUMN_SEARCH = "search"
|
||||
const val COLUMN_SEARCH_WITH_FILTERS = "search_with_filters"
|
||||
const val COLUMN_YEAR = "year"
|
||||
const val COLUMN_YEAR_RANGE = "year_range"
|
||||
const val COLUMN_ORIGINAL_LOCALE = "original_locale"
|
||||
const val COLUMN_ID = "id"
|
||||
const val COLUMN_NAME = "name"
|
||||
const val COLUMN_NUMBER = "number"
|
||||
@@ -282,5 +326,6 @@ class ExternalPluginContentSource(
|
||||
const val COLUMN_DESCRIPTION = "description"
|
||||
const val COLUMN_PREVIEW = "preview"
|
||||
const val COLUMN_KEY = "key"
|
||||
const val COLUMN_VALUE = "value"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,12 +37,12 @@ import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
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
|
||||
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.parsers.util.requireBody
|
||||
import java.net.HttpURLConnection
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
@@ -114,6 +114,7 @@ class FaviconFetcher(
|
||||
.url(url)
|
||||
.get()
|
||||
.tag(MangaSource::class.java, source)
|
||||
request.tag(MangaSource::class.java, source)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
options.tags.asMap().forEach { request.tag(it.key as Class<Any>, it.value) }
|
||||
val response = okHttpClient.newCall(request.build()).await()
|
||||
|
||||
@@ -160,6 +160,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isTrackerNsfwDisabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_TRACKER_NO_NSFW, false)
|
||||
|
||||
val trackerDownloadStrategy: TrackerDownloadStrategy
|
||||
get() = prefs.getEnumValue(KEY_TRACKER_DOWNLOAD, TrackerDownloadStrategy.DISABLED)
|
||||
|
||||
var notificationSound: Uri
|
||||
get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull()
|
||||
?: Settings.System.DEFAULT_NOTIFICATION_URI
|
||||
@@ -236,9 +239,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
}
|
||||
} ?: EnumSet.allOf(SearchSuggestionType::class.java)
|
||||
|
||||
val isLoggingEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
|
||||
|
||||
var isBiometricProtectionEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
|
||||
@@ -600,6 +600,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_TRACK_WARNING = "track_warning"
|
||||
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
|
||||
const val KEY_TRACKER_NO_NSFW = "tracker_no_nsfw"
|
||||
const val KEY_TRACKER_DOWNLOAD = "tracker_download"
|
||||
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
|
||||
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
|
||||
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
|
||||
@@ -661,7 +662,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_WEBTOON_ZOOM_OUT = "webtoon_zoom_out"
|
||||
const val KEY_PREFETCH_CONTENT = "prefetch_content"
|
||||
const val KEY_APP_LOCALE = "app_locale"
|
||||
const val KEY_LOGGING_ENABLED = "logging"
|
||||
const val KEY_SOURCES_GRID = "sources_grid"
|
||||
const val KEY_UPDATES_UNSTABLE = "updates_unstable"
|
||||
const val KEY_TIPS_CLOSED = "tips_closed"
|
||||
@@ -669,7 +669,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_READER_AUTOSCROLL_SPEED = "as_speed"
|
||||
const val KEY_MIRROR_SWITCHING = "mirror_switching"
|
||||
const val KEY_PROXY = "proxy"
|
||||
const val KEY_PROXY_TYPE = "proxy_type"
|
||||
const val KEY_PROXY_TYPE = "proxy_type_2"
|
||||
const val KEY_PROXY_ADDRESS = "proxy_address"
|
||||
const val KEY_PROXY_PORT = "proxy_port"
|
||||
const val KEY_PROXY_AUTH = "proxy_auth"
|
||||
@@ -705,9 +705,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_APP_VERSION = "app_version"
|
||||
const val KEY_IGNORE_DOZE = "ignore_dose"
|
||||
const val KEY_TRACKER_DEBUG = "tracker_debug"
|
||||
const val KEY_LOGS_SHARE = "logs_share"
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
const val KEY_APP_TRANSLATION = "about_app_translation"
|
||||
const val KEY_LINK_WEBLATE = "about_app_translation"
|
||||
const val KEY_LINK_TELEGRAM = "about_telegram"
|
||||
const val KEY_LINK_GITHUB = "about_github"
|
||||
const val KEY_LINK_MANUAL = "about_help"
|
||||
const val PROXY_TEST = "proxy_test"
|
||||
|
||||
// old keys are for migration only
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.StyleRes
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.parsers.util.find
|
||||
|
||||
@Keep
|
||||
enum class ColorScheme(
|
||||
@StyleRes val styleResId: Int,
|
||||
@StringRes val titleResId: Int,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
enum class DownloadFormat {
|
||||
|
||||
AUTOMATIC,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
enum class ListMode {
|
||||
|
||||
LIST, DETAILED_LIST, GRID;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.annotation.Keep
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
@Keep
|
||||
enum class NavItem(
|
||||
@IdRes val id: Int,
|
||||
@StringRes val title: Int,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
enum class NetworkPolicy(
|
||||
private val key: Int,
|
||||
) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
enum class ProgressIndicatorMode {
|
||||
|
||||
NONE, PERCENT_READ, PERCENT_LEFT, CHAPTERS_READ, CHAPTERS_LEFT;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
enum class ReaderAnimation {
|
||||
|
||||
// Do not rename this
|
||||
|
||||
@@ -2,11 +2,13 @@ package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import android.content.Context
|
||||
import android.view.ContextThemeWrapper
|
||||
import androidx.annotation.Keep
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@Keep
|
||||
enum class ReaderBackground {
|
||||
|
||||
DEFAULT, LIGHT, DARK, WHITE, BLACK;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
enum class ReaderMode(val id: Int) {
|
||||
|
||||
STANDARD(1),
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
enum class ScreenshotsPolicy {
|
||||
|
||||
// Do not rename this
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
@Keep
|
||||
enum class SearchSuggestionType(
|
||||
@StringRes val titleResId: Int,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
enum class TrackerDownloadStrategy {
|
||||
|
||||
DISABLED, DOWNLOADED;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.ui
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
@@ -100,11 +99,6 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// TODO fix behavior on Android 14
|
||||
dispatchNavigateUp()
|
||||
return true
|
||||
}
|
||||
val fm = supportFragmentManager
|
||||
if (fm.isStateSaved) {
|
||||
return false
|
||||
|
||||
@@ -80,11 +80,11 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
(activity as? SettingsActivity)?.setSectionTitle(title)
|
||||
}
|
||||
|
||||
protected fun startActivitySafe(intent: Intent) {
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
protected fun startActivitySafe(intent: Intent): Boolean = try {
|
||||
startActivity(intent)
|
||||
true
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ abstract class BaseViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
protected fun <T> Flow<T>.withErrorHandling() = catch { error ->
|
||||
error.printStackTraceDebug()
|
||||
errorEvent.call(error)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.PatternMatcher
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -20,7 +29,15 @@ abstract class CoroutineIntentService : BaseService() {
|
||||
|
||||
final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
launchCoroutine(intent, startId)
|
||||
val job = launchCoroutine(intent, startId)
|
||||
val receiver = CancelReceiver(job)
|
||||
ContextCompat.registerReceiver(
|
||||
this,
|
||||
receiver,
|
||||
createIntentFilter(this, startId),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
job.invokeOnCompletion { unregisterReceiver(receiver) }
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
@@ -47,8 +64,45 @@ abstract class CoroutineIntentService : BaseService() {
|
||||
@AnyThread
|
||||
protected abstract fun onError(startId: Int, error: Throwable)
|
||||
|
||||
protected fun getCancelIntent(startId: Int) = PendingIntentCompat.getBroadcast(
|
||||
this,
|
||||
0,
|
||||
createCancelIntent(this, startId),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
false,
|
||||
)
|
||||
|
||||
private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable ->
|
||||
throwable.printStackTraceDebug()
|
||||
onError(startId, throwable)
|
||||
}
|
||||
|
||||
private class CancelReceiver(
|
||||
private val job: Job
|
||||
) : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
private const val SCHEME = "startid"
|
||||
private const val ACTION_SUFFIX_CANCEL = ".ACTION_CANCEL"
|
||||
|
||||
fun createIntentFilter(service: CoroutineIntentService, startId: Int): IntentFilter {
|
||||
val intentFilter = IntentFilter(cancelAction(service))
|
||||
intentFilter.addDataScheme(SCHEME)
|
||||
intentFilter.addDataPath(startId.toString(), PatternMatcher.PATTERN_LITERAL)
|
||||
return intentFilter
|
||||
}
|
||||
|
||||
fun createCancelIntent(service: CoroutineIntentService, startId: Int): Intent {
|
||||
return Intent(cancelAction(service))
|
||||
.setData("$SCHEME://$startId".toUri())
|
||||
}
|
||||
|
||||
private fun cancelAction(service: CoroutineIntentService) = service.javaClass.name + ACTION_SUFFIX_CANCEL
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import android.view.View
|
||||
|
||||
fun interface OnContextClickListenerCompat {
|
||||
|
||||
fun onContextClick(v: View): Boolean
|
||||
}
|
||||
@@ -3,18 +3,46 @@ package org.koitharu.kotatsu.core.ui.list
|
||||
import android.view.View
|
||||
import android.view.View.OnClickListener
|
||||
import android.view.View.OnLongClickListener
|
||||
import androidx.core.util.Function
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
|
||||
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
|
||||
|
||||
class AdapterDelegateClickListenerAdapter<I>(
|
||||
class AdapterDelegateClickListenerAdapter<I, O>(
|
||||
private val adapterDelegate: AdapterDelegateViewBindingViewHolder<out I, *>,
|
||||
private val clickListener: OnListItemClickListener<I>,
|
||||
) : OnClickListener, OnLongClickListener {
|
||||
private val clickListener: OnListItemClickListener<O>,
|
||||
private val itemMapper: Function<I, O>,
|
||||
) : OnClickListener, OnLongClickListener, OnContextClickListenerCompat {
|
||||
|
||||
override fun onClick(v: View) {
|
||||
clickListener.onItemClick(adapterDelegate.item, v)
|
||||
clickListener.onItemClick(mappedItem(), v)
|
||||
}
|
||||
|
||||
override fun onLongClick(v: View): Boolean {
|
||||
return clickListener.onItemLongClick(adapterDelegate.item, v)
|
||||
return clickListener.onItemLongClick(mappedItem(), v)
|
||||
}
|
||||
|
||||
override fun onContextClick(v: View): Boolean {
|
||||
return clickListener.onItemContextClick(mappedItem(), v)
|
||||
}
|
||||
|
||||
private fun mappedItem(): O = itemMapper.apply(adapterDelegate.item)
|
||||
|
||||
fun attach(itemView: View) {
|
||||
itemView.setOnClickListener(this)
|
||||
itemView.setOnLongClickListener(this)
|
||||
itemView.setOnContextClickListenerCompat(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
operator fun <T> invoke(
|
||||
adapterDelegate: AdapterDelegateViewBindingViewHolder<out T, *>,
|
||||
clickListener: OnListItemClickListener<T>
|
||||
): AdapterDelegateClickListenerAdapter<T, T> = AdapterDelegateClickListenerAdapter(
|
||||
adapterDelegate = adapterDelegate,
|
||||
clickListener = clickListener,
|
||||
itemMapper = { x -> x },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@ package org.koitharu.kotatsu.core.ui.list
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.collection.LongSet
|
||||
import androidx.collection.longSetOf
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
@@ -29,18 +33,21 @@ class ListSelectionController(
|
||||
) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
|
||||
|
||||
private var actionMode: ActionMode? = null
|
||||
private var focusedItemId: LongSet? = null
|
||||
|
||||
var useActionMode: Boolean = true
|
||||
|
||||
val count: Int
|
||||
get() = decoration.checkedItemsCount
|
||||
get() = if (focusedItemId != null) 1 else decoration.checkedItemsCount
|
||||
|
||||
init {
|
||||
registryOwner.lifecycle.addObserver(StateEventObserver())
|
||||
}
|
||||
|
||||
fun snapshot(): Set<Long> = peekCheckedIds().toSet()
|
||||
fun snapshot(): Set<Long> = (focusedItemId ?: peekCheckedIds()).toSet()
|
||||
|
||||
fun peekCheckedIds(): LongSet {
|
||||
return decoration.checkedItemsIds
|
||||
return focusedItemId ?: decoration.checkedItemsIds
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
@@ -52,6 +59,7 @@ class ListSelectionController(
|
||||
if (ids.isEmpty()) {
|
||||
return
|
||||
}
|
||||
startActionMode()
|
||||
decoration.checkAll(ids)
|
||||
notifySelectionChanged()
|
||||
}
|
||||
@@ -80,15 +88,42 @@ class ListSelectionController(
|
||||
return false
|
||||
}
|
||||
|
||||
fun onItemLongClick(id: Long): Boolean {
|
||||
return startActionMode()?.also {
|
||||
decoration.setItemIsChecked(id, true)
|
||||
notifySelectionChanged()
|
||||
} != null
|
||||
fun onItemLongClick(view: View, id: Long): Boolean {
|
||||
return if (useActionMode) {
|
||||
startSelection(id)
|
||||
} else {
|
||||
onItemContextClick(view, id)
|
||||
}
|
||||
}
|
||||
|
||||
fun onItemContextClick(view: View, id: Long): Boolean {
|
||||
focusedItemId = longSetOf(id)
|
||||
val menu = PopupMenu(view.context, view)
|
||||
callback.onCreateActionMode(this, menu.menuInflater, menu.menu)
|
||||
callback.onPrepareActionMode(this, null, menu.menu)
|
||||
menu.setForceShowIcon(true)
|
||||
if (menu.menu.hasVisibleItems()) {
|
||||
menu.setOnMenuItemClickListener { menuItem ->
|
||||
callback.onActionItemClicked(this, null, menuItem)
|
||||
}
|
||||
menu.setOnDismissListener {
|
||||
focusedItemId = null
|
||||
}
|
||||
menu.show()
|
||||
return true
|
||||
} else {
|
||||
focusedItemId = null
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fun startSelection(id: Long): Boolean = startActionMode()?.also {
|
||||
decoration.setItemIsChecked(id, true)
|
||||
notifySelectionChanged()
|
||||
} != null
|
||||
|
||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
return callback.onCreateActionMode(this, mode, menu)
|
||||
return callback.onCreateActionMode(this, mode.menuInflater, menu)
|
||||
}
|
||||
|
||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
@@ -106,6 +141,7 @@ class ListSelectionController(
|
||||
}
|
||||
|
||||
private fun startActionMode(): ActionMode? {
|
||||
focusedItemId = null
|
||||
return actionMode ?: appCompatDelegate.startSupportActionMode(this).also {
|
||||
actionMode = it
|
||||
}
|
||||
@@ -134,14 +170,14 @@ class ListSelectionController(
|
||||
|
||||
fun onSelectionChanged(controller: ListSelectionController, count: Int)
|
||||
|
||||
fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean
|
||||
fun onCreateActionMode(controller: ListSelectionController, menuInflater: MenuInflater, menu: Menu): Boolean
|
||||
|
||||
fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.title = controller.count.toString()
|
||||
fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean {
|
||||
mode?.title = controller.count.toString()
|
||||
return true
|
||||
}
|
||||
|
||||
fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean
|
||||
fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean
|
||||
|
||||
fun onDestroyActionMode(controller: ListSelectionController, mode: ActionMode) = Unit
|
||||
}
|
||||
|
||||
@@ -6,5 +6,7 @@ fun interface OnListItemClickListener<I> {
|
||||
|
||||
fun onItemClick(item: I, view: View)
|
||||
|
||||
fun onItemLongClick(item: I, view: View) = false
|
||||
fun onItemLongClick(item: I, view: View): Boolean = false
|
||||
|
||||
fun onItemContextClick(item: I, view: View): Boolean = onItemLongClick(item, view)
|
||||
}
|
||||
|
||||
@@ -4,14 +4,22 @@ import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.SortDirection
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.ADDED
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.ADDED_ASC
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.ALPHABETICAL
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.ALPHABETICAL_DESC
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.NEWEST
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.NEWEST_ASC
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_ASC
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_HOUR
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_MONTH
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_TODAY
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_WEEK
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_YEAR
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.RATING
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.RATING_ASC
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.RELEVANCE
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED_ASC
|
||||
|
||||
@@ -28,6 +36,14 @@ val SortOrder.titleRes: Int
|
||||
POPULARITY_ASC -> R.string.unpopular
|
||||
RATING_ASC -> R.string.low_rating
|
||||
NEWEST_ASC -> R.string.order_oldest
|
||||
ADDED -> R.string.recently_added
|
||||
ADDED_ASC -> R.string.added_long_ago
|
||||
RELEVANCE -> R.string.by_relevance
|
||||
POPULARITY_HOUR -> R.string.popular_in_hour
|
||||
POPULARITY_TODAY -> R.string.popular_today
|
||||
POPULARITY_WEEK -> R.string.popular_in_week
|
||||
POPULARITY_MONTH -> R.string.popular_in_month
|
||||
POPULARITY_YEAR -> R.string.popular_in_year
|
||||
}
|
||||
|
||||
val SortOrder.direction: SortDirection
|
||||
@@ -36,11 +52,19 @@ val SortOrder.direction: SortDirection
|
||||
POPULARITY_ASC,
|
||||
RATING_ASC,
|
||||
NEWEST_ASC,
|
||||
ADDED_ASC,
|
||||
ALPHABETICAL -> SortDirection.ASC
|
||||
|
||||
UPDATED,
|
||||
POPULARITY,
|
||||
POPULARITY_HOUR,
|
||||
POPULARITY_TODAY,
|
||||
POPULARITY_WEEK,
|
||||
POPULARITY_MONTH,
|
||||
POPULARITY_YEAR,
|
||||
RATING,
|
||||
NEWEST,
|
||||
ADDED,
|
||||
RELEVANCE,
|
||||
ALPHABETICAL_DESC -> SortDirection.DESC
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.view.MenuProvider
|
||||
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
|
||||
|
||||
class PopupMenuMediator(
|
||||
private val provider: MenuProvider,
|
||||
) : View.OnLongClickListener, PopupMenu.OnMenuItemClickListener, PopupMenu.OnDismissListener {
|
||||
) : View.OnLongClickListener, OnContextClickListenerCompat, PopupMenu.OnMenuItemClickListener,
|
||||
PopupMenu.OnDismissListener {
|
||||
|
||||
override fun onContextClick(v: View): Boolean = onLongClick(v)
|
||||
|
||||
override fun onLongClick(v: View): Boolean {
|
||||
val menu = PopupMenu(v.context, v)
|
||||
|
||||
@@ -8,18 +8,32 @@ import androidx.annotation.ColorRes
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.view.children
|
||||
import coil.ImageLoader
|
||||
import coil.request.Disposable
|
||||
import coil.request.ImageRequest
|
||||
import coil.transform.RoundedCornersTransformation
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.chip.ChipDrawable
|
||||
import com.google.android.material.chip.ChipGroup
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.setProgressIcon
|
||||
import org.koitharu.kotatsu.parsers.util.ifZero
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ChipsView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = com.google.android.material.R.attr.chipGroupStyle,
|
||||
) : ChipGroup(context, attrs, defStyleAttr) {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private var isLayoutSuppressedCompat = false
|
||||
private var isLayoutCalledOnSuppressed = false
|
||||
private val chipOnClickListener = InternalChipClickListener()
|
||||
@@ -36,11 +50,6 @@ class ChipsView @JvmOverloads constructor(
|
||||
children.forEach { it.isClickable = isChipClickable }
|
||||
}
|
||||
var onChipCloseClickListener: OnChipCloseClickListener? = null
|
||||
set(value) {
|
||||
field = value
|
||||
val isCloseIconVisible = value != null
|
||||
children.forEach { (it as? Chip)?.isCloseIconVisible = isCloseIconVisible }
|
||||
}
|
||||
|
||||
init {
|
||||
val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0)
|
||||
@@ -95,15 +104,19 @@ class ChipsView @JvmOverloads constructor(
|
||||
val title: CharSequence? = null,
|
||||
@StringRes val titleResId: Int = 0,
|
||||
@DrawableRes val icon: Int = 0,
|
||||
val iconData: Any? = null,
|
||||
@ColorRes val tint: Int = 0,
|
||||
val isChecked: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val isDropdown: Boolean = false,
|
||||
val isCloseable: Boolean = false,
|
||||
val data: Any? = null,
|
||||
)
|
||||
|
||||
private inner class DataChip(context: Context) : Chip(context) {
|
||||
|
||||
private var model: ChipModel? = null
|
||||
private var imageRequest: Disposable? = null
|
||||
|
||||
init {
|
||||
val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
|
||||
@@ -116,6 +129,9 @@ class ChipsView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
fun bind(model: ChipModel) {
|
||||
if (this.model == model) {
|
||||
return
|
||||
}
|
||||
this.model = model
|
||||
|
||||
if (model.titleResId == 0) {
|
||||
@@ -131,15 +147,9 @@ class ChipsView @JvmOverloads constructor(
|
||||
isChecked = false
|
||||
isCheckable = false
|
||||
}
|
||||
if (model.icon == 0 || model.isChecked) {
|
||||
chipIcon = null
|
||||
isChipIconVisible = false
|
||||
} else {
|
||||
setChipIconResource(model.icon)
|
||||
isChipIconVisible = true
|
||||
}
|
||||
bindIcon(model)
|
||||
isCheckedIconVisible = model.isChecked
|
||||
isCloseIconVisible = if (onChipCloseClickListener != null || model.isDropdown) {
|
||||
isCloseIconVisible = if (model.isCloseable || model.isDropdown) {
|
||||
setCloseIconResource(
|
||||
if (model.isDropdown) R.drawable.ic_expand_more else materialR.drawable.ic_m3_chip_close,
|
||||
)
|
||||
@@ -151,6 +161,54 @@ class ChipsView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
override fun toggle() = Unit
|
||||
|
||||
private fun bindIcon(model: ChipModel) {
|
||||
when {
|
||||
model.isChecked -> {
|
||||
imageRequest?.dispose()
|
||||
imageRequest = null
|
||||
chipIcon = null
|
||||
isChipIconVisible = false
|
||||
}
|
||||
|
||||
model.isLoading -> {
|
||||
imageRequest?.dispose()
|
||||
imageRequest = null
|
||||
isChipIconVisible = true
|
||||
setProgressIcon()
|
||||
}
|
||||
|
||||
model.iconData != null -> {
|
||||
val placeholder = model.icon.ifZero { materialR.drawable.navigation_empty_icon }
|
||||
imageRequest = ImageRequest.Builder(context)
|
||||
.data(model.iconData)
|
||||
.crossfade(false)
|
||||
.size(resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
|
||||
.target(ChipIconTarget(this))
|
||||
.placeholder(placeholder)
|
||||
.fallback(placeholder)
|
||||
.error(placeholder)
|
||||
.transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.chip_icon_corner)))
|
||||
.allowRgb565(true)
|
||||
.enqueueWith(coil)
|
||||
isChipIconVisible = true
|
||||
}
|
||||
|
||||
model.icon != 0 -> {
|
||||
imageRequest?.dispose()
|
||||
imageRequest = null
|
||||
setChipIconResource(model.icon)
|
||||
isChipIconVisible = true
|
||||
}
|
||||
|
||||
else -> {
|
||||
imageRequest?.dispose()
|
||||
imageRequest = null
|
||||
chipIcon = null
|
||||
isChipIconVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class InternalChipClickListener : OnClickListener {
|
||||
|
||||
@@ -2,12 +2,10 @@ package org.koitharu.kotatsu.core.util
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.logs.FileLogger
|
||||
import org.koitharu.kotatsu.core.model.appUrl
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.io.File
|
||||
@@ -84,25 +82,4 @@ class ShareHelper(private val context: Context) {
|
||||
.setChooserTitle(R.string.share)
|
||||
.startChooser()
|
||||
}
|
||||
|
||||
fun shareLogs(loggers: Collection<FileLogger>) {
|
||||
val intentBuilder = ShareCompat.IntentBuilder(context)
|
||||
.setType(TYPE_TEXT)
|
||||
var hasLogs = false
|
||||
for (logger in loggers) {
|
||||
val logFile = logger.file
|
||||
if (!logFile.exists()) {
|
||||
continue
|
||||
}
|
||||
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", logFile)
|
||||
intentBuilder.addStream(uri)
|
||||
hasLogs = true
|
||||
}
|
||||
if (hasLogs) {
|
||||
intentBuilder.setChooserTitle(R.string.share_logs)
|
||||
intentBuilder.startChooser()
|
||||
} else {
|
||||
Toast.makeText(context, R.string.nothing_here, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.core.os.BundleCompat
|
||||
import androidx.core.os.ParcelCompat
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import java.io.Serializable
|
||||
import java.util.EnumSet
|
||||
|
||||
// https://issuetracker.google.com/issues/240585930
|
||||
|
||||
@@ -53,6 +54,31 @@ inline fun <reified T : Serializable> Bundle.requireSerializable(key: String): T
|
||||
}
|
||||
}
|
||||
|
||||
fun <E : Enum<E>> Parcel.writeEnumSet(set: Set<E>?) {
|
||||
if (set == null) {
|
||||
writeValue(null)
|
||||
} else {
|
||||
val array = IntArray(set.size)
|
||||
set.forEachIndexed { i, e -> array[i] = e.ordinal }
|
||||
writeIntArray(array)
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified E : Enum<E>> Parcel.readEnumSet(): Set<E>? = readEnumSet(E::class.java)
|
||||
|
||||
fun <E : Enum<E>> Parcel.readEnumSet(cls: Class<E>): Set<E>? {
|
||||
val array = createIntArray() ?: return null
|
||||
if (array.isEmpty()) {
|
||||
return emptySet()
|
||||
}
|
||||
val enumValues = cls.enumConstants ?: return null
|
||||
val set = EnumSet.noneOf(cls)
|
||||
array.forEach { e ->
|
||||
set.add(enumValues[e])
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
fun <T> SavedStateHandle.require(key: String): T {
|
||||
return checkNotNull(get(key)) {
|
||||
"Value $key not found in SavedStateHandle or has a wrong type"
|
||||
|
||||
@@ -25,6 +25,12 @@ fun <T> Collection<T>.asArrayList(): ArrayList<T> = if (this is ArrayList<*>) {
|
||||
ArrayList(this)
|
||||
}
|
||||
|
||||
fun <E : Enum<E>> Set<E>.asEnumSet(cls: Class<E>): EnumSet<E> = if (this is EnumSet<*>) {
|
||||
this as EnumSet<E>
|
||||
} else {
|
||||
EnumSet.noneOf(cls).apply { addAll(this@asEnumSet) }
|
||||
}
|
||||
|
||||
fun <K, V> Map<K, V>.findKeyByValue(value: V): K? {
|
||||
for ((k, v) in entries) {
|
||||
if (v == value) {
|
||||
@@ -91,3 +97,14 @@ fun LongSet.toSet(): Set<Long> = toCollection(ArraySet<Long>(size))
|
||||
fun <R : MutableCollection<Long>> LongSet.toCollection(out: R): R = out.also { result ->
|
||||
forEach(result::add)
|
||||
}
|
||||
|
||||
fun <T, R> Collection<T>.mapSortedByCount(isDescending: Boolean = true, mapper: (T) -> R): List<R> {
|
||||
val grouped = groupBy(mapper).toList()
|
||||
val sortSelector: (Pair<R, List<T>>) -> Int = { it.second.size }
|
||||
val sorted = if (isDescending) {
|
||||
grouped.sortedByDescending(sortSelector)
|
||||
} else {
|
||||
grouped.sortedBy(sortSelector)
|
||||
}
|
||||
return sorted.map { it.first }
|
||||
}
|
||||
|
||||
@@ -10,10 +10,13 @@ import android.provider.OpenableColumns
|
||||
import androidx.core.database.getStringOrNull
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.internal.closeQuietly
|
||||
import org.jetbrains.annotations.Blocking
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.fs.FileSequence
|
||||
import java.io.File
|
||||
import java.io.FileFilter
|
||||
import java.io.InputStream
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
@@ -32,10 +35,19 @@ fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() }
|
||||
|
||||
fun File.isNotEmpty() = length() != 0L
|
||||
|
||||
@Blocking
|
||||
fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().use {
|
||||
it.readText()
|
||||
}
|
||||
|
||||
@Blocking
|
||||
fun ZipFile.getInputStreamOrClose(entry: ZipEntry): InputStream = try {
|
||||
getInputStream(entry)
|
||||
} catch (e: Throwable) {
|
||||
closeQuietly()
|
||||
throw e
|
||||
}
|
||||
|
||||
fun File.getStorageName(context: Context): String = runCatching {
|
||||
val manager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
|
||||
@@ -5,17 +5,20 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.transform
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.flow.transformWhile
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@@ -101,7 +104,8 @@ fun <T> Flow<T>.withTicker(interval: Long, timeUnit: TimeUnit) = channelFlow<T>
|
||||
onCompletion { cause ->
|
||||
close(cause)
|
||||
}.combine(tickerFlow(interval, timeUnit)) { x, _ -> x }
|
||||
.collectLatest { send(it) }
|
||||
.transformWhile<T, Unit> { trySend(it).isSuccess }
|
||||
.collect()
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@@ -127,3 +131,7 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
|
||||
suspend fun <T : Any> Flow<T?>.firstNotNull(): T = checkNotNull(first { x -> x != null })
|
||||
|
||||
suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x != null }
|
||||
|
||||
fun <T> Flow<Flow<T>>.flattenLatest() = flatMapLatest { it }
|
||||
|
||||
fun <T> SuspendLazy<T>.asFlow() = flow { emit(tryGet()) }
|
||||
|
||||
@@ -5,7 +5,6 @@ import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.IOException
|
||||
import org.json.JSONObject
|
||||
@@ -41,8 +40,6 @@ fun Response.ensureSuccess() = apply {
|
||||
}
|
||||
}
|
||||
|
||||
fun Response.requireBody(): ResponseBody = checkNotNull(body) { "Response body is null" }
|
||||
|
||||
fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
|
||||
c.name(name)
|
||||
c.value(value)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
inline fun Int.ifZero(defaultValue: () -> Int): Int = if (this == 0) defaultValue() else this
|
||||
|
||||
inline fun Long.ifZero(defaultValue: () -> Long): Long = if (this == 0L) defaultValue() else this
|
||||
|
||||
fun longOf(a: Int, b: Int): Long {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.content.Context
|
||||
import android.database.DatabaseUtils
|
||||
import androidx.annotation.FloatRange
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.parsers.util.ellipsize
|
||||
@@ -64,3 +65,11 @@ fun <T> Collection<T>.joinToStringWithLimit(context: Context, limit: Int, transf
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("",
|
||||
ReplaceWith(
|
||||
"sqlEscapeString(this)",
|
||||
"android.database.DatabaseUtils.sqlEscapeString"
|
||||
)
|
||||
)
|
||||
fun String.sqlEscape(): String = DatabaseUtils.sqlEscapeString(this)
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.ActivityNotFoundException
|
||||
import android.content.res.Resources
|
||||
import androidx.annotation.DrawableRes
|
||||
import coil.network.HttpException
|
||||
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||
import okio.FileNotFoundException
|
||||
import okio.IOException
|
||||
import okio.ProtocolException
|
||||
@@ -80,6 +81,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
|
||||
is UnknownHostException,
|
||||
is SocketTimeoutException -> resources.getString(R.string.network_error)
|
||||
|
||||
is ImageDecodeException -> resources.getString(R.string.error_corrupted_file)
|
||||
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)
|
||||
@@ -89,7 +91,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
|
||||
is HttpException -> getHttpDisplayMessage(response.code, resources)
|
||||
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
|
||||
|
||||
else -> getDisplayMessage(message, resources) ?: localizedMessage
|
||||
else -> getDisplayMessage(message, resources) ?: message
|
||||
}.ifNullOrEmpty {
|
||||
resources.getString(R.string.error_occurred)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ fun Uri.source(): Source = when (scheme) {
|
||||
URI_SCHEME_ZIP -> {
|
||||
val zip = ZipFile(schemeSpecificPart)
|
||||
val entry = zip.getEntry(fragment)
|
||||
zip.getInputStream(entry).source().withExtraCloseable(zip)
|
||||
zip.getInputStreamOrClose(entry).source().withExtraCloseable(zip)
|
||||
}
|
||||
|
||||
else -> unsupportedUri(this)
|
||||
|
||||
@@ -18,8 +18,10 @@ import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.progressindicator.BaseProgressIndicator
|
||||
import com.google.android.material.slider.RangeSlider
|
||||
import com.google.android.material.slider.Slider
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
|
||||
@@ -88,6 +90,17 @@ fun Slider.setValueRounded(newValue: Float) {
|
||||
value = roundedValue.coerceIn(valueFrom, valueTo)
|
||||
}
|
||||
|
||||
fun RangeSlider.setValuesRounded(vararg newValues: Float) {
|
||||
val step = stepSize
|
||||
values = newValues.map { newValue ->
|
||||
if (step <= 0f) {
|
||||
newValue
|
||||
} else {
|
||||
(newValue / step).roundToInt() * step
|
||||
}.coerceIn(valueFrom, valueTo)
|
||||
}
|
||||
}
|
||||
|
||||
fun RecyclerView.invalidateNestedItemDecorations() {
|
||||
descendants.filterIsInstance<RecyclerView>().forEach {
|
||||
it.invalidateItemDecorations()
|
||||
@@ -141,9 +154,9 @@ fun BaseProgressIndicator<*>.showOrHide(value: Boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
fun View.setOnContextClickListenerCompat(listener: View.OnLongClickListener) {
|
||||
fun View.setOnContextClickListenerCompat(listener: OnContextClickListenerCompat) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
setOnContextClickListener(listener::onLongClick)
|
||||
setOnContextClickListener(listener::onContextClick)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
|
||||
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
@@ -98,13 +99,13 @@ 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.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.util.ellipsize
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
|
||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||
import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
@@ -114,7 +115,8 @@ class DetailsActivity :
|
||||
BaseActivity<ActivityDetailsBinding>(),
|
||||
View.OnClickListener,
|
||||
View.OnLongClickListener, PopupMenu.OnMenuItemClickListener, View.OnLayoutChangeListener,
|
||||
ViewTreeObserver.OnDrawListener, ChipsView.OnChipClickListener, OnListItemClickListener<Bookmark> {
|
||||
ViewTreeObserver.OnDrawListener, ChipsView.OnChipClickListener, OnListItemClickListener<Bookmark>,
|
||||
OnContextClickListenerCompat {
|
||||
|
||||
@Inject
|
||||
lateinit var shortcutManager: AppShortcutManager
|
||||
@@ -213,10 +215,10 @@ class DetailsActivity :
|
||||
R.id.chip_author -> {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
startActivity(
|
||||
SearchActivity.newIntent(
|
||||
MangaListActivity.newIntent(
|
||||
context = v.context,
|
||||
source = manga.source,
|
||||
query = manga.author ?: return,
|
||||
filter = MangaListFilter(query = manga.author),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -227,6 +229,7 @@ class DetailsActivity :
|
||||
MangaListActivity.newIntent(
|
||||
context = v.context,
|
||||
source = manga.source,
|
||||
filter = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -286,9 +289,12 @@ class DetailsActivity :
|
||||
|
||||
override fun onChipClick(chip: Chip, data: Any?) {
|
||||
val tag = data as? MangaTag ?: return
|
||||
startActivity(MangaListActivity.newIntent(this, setOf(tag)))
|
||||
// TODO dialog
|
||||
startActivity(MangaListActivity.newIntent(this, tag.source, MangaListFilter(tags = setOf(tag))))
|
||||
}
|
||||
|
||||
override fun onContextClick(v: View): Boolean = onLongClick(v)
|
||||
|
||||
override fun onLongClick(v: View): Boolean = when (v.id) {
|
||||
R.id.button_read -> {
|
||||
val menu = PopupMenu(v.context, v)
|
||||
|
||||
@@ -23,7 +23,7 @@ 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.scrobbling.common.ui.selector.ScrobblingSelectorSheet
|
||||
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
|
||||
import org.koitharu.kotatsu.search.ui.multi.SearchActivity
|
||||
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
|
||||
|
||||
class DetailsMenuProvider(
|
||||
@@ -92,7 +92,7 @@ class DetailsMenuProvider(
|
||||
|
||||
R.id.action_related -> {
|
||||
viewModel.manga.value?.let {
|
||||
activity.startActivity(MultiSearchActivity.newIntent(activity, it.title))
|
||||
activity.startActivity(SearchActivity.newIntent(activity, it.title))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,7 @@ fun chapterGridItemAD(
|
||||
on = { item, _, _ -> item is ChapterListItem && item.isGrid },
|
||||
) {
|
||||
|
||||
val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||
itemView.setOnClickListener(eventListener)
|
||||
itemView.setOnLongClickListener(eventListener)
|
||||
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
||||
|
||||
bind { payloads ->
|
||||
if (payloads.isEmpty()) {
|
||||
|
||||
@@ -22,9 +22,7 @@ fun chapterListItemAD(
|
||||
on = { item, _, _ -> item is ChapterListItem && !item.isGrid },
|
||||
) {
|
||||
|
||||
val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||
itemView.setOnClickListener(eventListener)
|
||||
itemView.setOnLongClickListener(eventListener)
|
||||
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.chapter.name
|
||||
|
||||
@@ -166,8 +166,9 @@ abstract class ChaptersPagesViewModel(
|
||||
fun download(chaptersIds: Set<Long>?) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
downloadScheduler.schedule(
|
||||
requireManga(),
|
||||
chaptersIds,
|
||||
manga = requireManga(),
|
||||
chaptersIds = chaptersIds,
|
||||
isSilent = false,
|
||||
)
|
||||
onDownloadStarted.call(Unit)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.details.ui.pager.bookmarks
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -133,7 +134,11 @@ class BookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
|
||||
return selectionController?.onItemLongClick(item.pageId) ?: false
|
||||
return selectionController?.onItemLongClick(view, item.pageId) ?: false
|
||||
}
|
||||
|
||||
override fun onItemContextClick(item: Bookmark, view: View): Boolean {
|
||||
return selectionController?.onItemContextClick(view, item.pageId) ?: false
|
||||
}
|
||||
|
||||
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
|
||||
@@ -142,23 +147,23 @@ class BookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
|
||||
|
||||
override fun onCreateActionMode(
|
||||
controller: ListSelectionController,
|
||||
mode: ActionMode,
|
||||
menuInflater: MenuInflater,
|
||||
menu: Menu,
|
||||
): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
|
||||
menuInflater.inflate(R.menu.mode_bookmarks, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(
|
||||
controller: ListSelectionController,
|
||||
mode: ActionMode,
|
||||
mode: ActionMode?,
|
||||
item: MenuItem,
|
||||
): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_remove -> {
|
||||
val ids = selectionController?.snapshot() ?: return false
|
||||
viewModel.removeBookmarks(ids)
|
||||
mode.finish()
|
||||
mode?.finish()
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -116,7 +116,11 @@ class ChaptersFragment :
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: ChapterListItem, view: View): Boolean {
|
||||
return selectionController?.onItemLongClick(item.chapter.id) ?: false
|
||||
return selectionController?.onItemLongClick(view, item.chapter.id) ?: false
|
||||
}
|
||||
|
||||
override fun onItemContextClick(item: ChapterListItem, view: View): Boolean {
|
||||
return selectionController?.onItemContextClick(view, item.chapter.id) ?: false
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
||||
@@ -149,7 +153,7 @@ class ChaptersFragment :
|
||||
items?.indexOfFirst(predicate) ?: -1
|
||||
}
|
||||
if (position >= 0) {
|
||||
selectionController?.onItemLongClick(chapterId)
|
||||
selectionController?.startSelection(chapterId)
|
||||
val lm = (viewBinding?.recyclerViewChapters?.layoutManager as? LinearLayoutManager)
|
||||
if (lm != null) {
|
||||
val offset = resources.getDimensionPixelOffset(R.dimen.chapter_list_item_height)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager.chapters
|
||||
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -19,12 +20,16 @@ class ChaptersSelectionCallback(
|
||||
recyclerView: RecyclerView,
|
||||
) : BaseListSelectionCallback(recyclerView) {
|
||||
|
||||
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
|
||||
override fun onCreateActionMode(
|
||||
controller: ListSelectionController,
|
||||
menuInflater: MenuInflater,
|
||||
menu: Menu
|
||||
): Boolean {
|
||||
menuInflater.inflate(R.menu.mode_chapters, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean {
|
||||
val selectedIds = controller.peekCheckedIds()
|
||||
val allItems = viewModel.chapters.value
|
||||
val items = allItems.withIndex().filter { it.value.chapter.id in selectedIds }
|
||||
@@ -38,7 +43,7 @@ class ChaptersSelectionCallback(
|
||||
menu.findItem(R.id.action_delete).isVisible = canDelete
|
||||
menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size
|
||||
menu.findItem(R.id.action_mark_current).isVisible = items.size == 1
|
||||
mode.title = items.size.toString()
|
||||
mode?.title = items.size.toString()
|
||||
var hasGap = false
|
||||
for (i in 0 until items.size - 1) {
|
||||
if (items[i].index + 1 != items[i + 1].index) {
|
||||
@@ -50,11 +55,11 @@ class ChaptersSelectionCallback(
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
|
||||
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_save -> {
|
||||
viewModel.download(controller.snapshot())
|
||||
mode.finish()
|
||||
mode?.finish()
|
||||
true
|
||||
}
|
||||
|
||||
@@ -73,7 +78,7 @@ class ChaptersSelectionCallback(
|
||||
).show()
|
||||
}
|
||||
}
|
||||
mode.finish()
|
||||
mode?.finish()
|
||||
true
|
||||
}
|
||||
|
||||
@@ -112,7 +117,7 @@ class ChaptersSelectionCallback(
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
mode.finish()
|
||||
mode?.finish()
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import okio.source
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.getInputStreamOrClose
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.local.data.isFileUri
|
||||
import org.koitharu.kotatsu.local.data.isZipUri
|
||||
@@ -68,7 +69,7 @@ class MangaPageFetcher(
|
||||
val entry = zip.getEntry(uri.fragment)
|
||||
SourceResult(
|
||||
source = ImageSource(
|
||||
source = zip.getInputStream(entry).source().withExtraCloseable(zip).buffer(),
|
||||
source = zip.getInputStreamOrClose(entry).source().withExtraCloseable(zip).buffer(),
|
||||
context = context,
|
||||
metadata = MangaPageMetadata(page),
|
||||
),
|
||||
|
||||
@@ -32,9 +32,7 @@ fun pageThumbnailAD(
|
||||
height = (gridWidth / 13f * 18f).toInt(),
|
||||
)
|
||||
|
||||
val clickListenerAdapter = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||
binding.root.setOnClickListener(clickListenerAdapter)
|
||||
binding.root.setOnLongClickListener(clickListenerAdapter)
|
||||
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
||||
|
||||
bind {
|
||||
val data: Any = item.page.preview?.takeUnless { it.isEmpty() } ?: item.page.toMangaPage()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.koitharu.kotatsu.details.ui.related
|
||||
|
||||
import android.view.Menu
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import android.view.MenuInflater
|
||||
import androidx.fragment.app.viewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -16,9 +16,13 @@ class RelatedListFragment : MangaListFragment() {
|
||||
|
||||
override fun onScrolledToEnd() = Unit
|
||||
|
||||
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.mode_remote, menu)
|
||||
return super.onCreateActionMode(controller, mode, menu)
|
||||
override fun onCreateActionMode(
|
||||
controller: ListSelectionController,
|
||||
menuInflater: MenuInflater,
|
||||
menu: Menu
|
||||
): Boolean {
|
||||
menuInflater.inflate(R.menu.mode_remote, menu)
|
||||
return super.onCreateActionMode(controller, menuInflater, menu)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.download.ui.list
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.activity.viewModels
|
||||
@@ -58,9 +59,7 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
|
||||
RecyclerScrollKeeper(this).attach()
|
||||
}
|
||||
addMenuProvider(DownloadsMenuProvider(this, viewModel))
|
||||
viewModel.items.observe(this) {
|
||||
downloadsAdapter.items = it
|
||||
}
|
||||
viewModel.items.observe(this, downloadsAdapter)
|
||||
viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.recyclerView))
|
||||
val menuInvalidator = MenuInvalidator(this)
|
||||
viewModel.hasActiveWorks.observe(this, menuInvalidator)
|
||||
@@ -89,7 +88,11 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean {
|
||||
return selectionController.onItemLongClick(item.id.mostSignificantBits)
|
||||
return selectionController.onItemLongClick(view, item.id.mostSignificantBits)
|
||||
}
|
||||
|
||||
override fun onItemContextClick(item: DownloadItemModel, view: View): Boolean {
|
||||
return selectionController.onItemContextClick(view, item.id.mostSignificantBits)
|
||||
}
|
||||
|
||||
override fun onExpandClick(item: DownloadItemModel) {
|
||||
@@ -122,34 +125,38 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
|
||||
viewBinding.recyclerView.invalidateItemDecorations()
|
||||
}
|
||||
|
||||
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.mode_downloads, menu)
|
||||
override fun onCreateActionMode(
|
||||
controller: ListSelectionController,
|
||||
menuInflater: MenuInflater,
|
||||
menu: Menu
|
||||
): Boolean {
|
||||
menuInflater.inflate(R.menu.mode_downloads, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
|
||||
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_resume -> {
|
||||
viewModel.resume(controller.snapshot())
|
||||
mode.finish()
|
||||
mode?.finish()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_pause -> {
|
||||
viewModel.pause(controller.snapshot())
|
||||
mode.finish()
|
||||
mode?.finish()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_cancel -> {
|
||||
viewModel.cancel(controller.snapshot())
|
||||
mode.finish()
|
||||
mode?.finish()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_remove -> {
|
||||
viewModel.remove(controller.snapshot())
|
||||
mode.finish()
|
||||
mode?.finish()
|
||||
true
|
||||
}
|
||||
|
||||
@@ -162,7 +169,7 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean {
|
||||
val snapshot = viewModel.snapshot(controller.peekCheckedIds())
|
||||
var canPause = true
|
||||
var canResume = true
|
||||
|
||||
@@ -37,7 +37,8 @@ import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||
import java.util.UUID
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
private const val CHANNEL_ID = "download"
|
||||
private const val CHANNEL_ID_DEFAULT = "download"
|
||||
private const val CHANNEL_ID_SILENT = "download_bg"
|
||||
private const val GROUP_ID = "downloads"
|
||||
|
||||
class DownloadNotificationFactory @AssistedInject constructor(
|
||||
@@ -45,10 +46,11 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
||||
private val workManager: WorkManager,
|
||||
private val coil: ImageLoader,
|
||||
@Assisted private val uuid: UUID,
|
||||
@Assisted val isSilent: Boolean,
|
||||
) {
|
||||
|
||||
private val covers = HashMap<Manga, Drawable>()
|
||||
private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
private val builder = NotificationCompat.Builder(context, if (isSilent) CHANNEL_ID_SILENT else CHANNEL_ID_DEFAULT)
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val coverWidth = context.resources.getDimensionPixelSize(
|
||||
@@ -106,14 +108,18 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
||||
}
|
||||
|
||||
init {
|
||||
createChannel()
|
||||
createChannels()
|
||||
builder.setOnlyAlertOnce(true)
|
||||
builder.setDefaults(0)
|
||||
builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
|
||||
builder.foregroundServiceBehavior = if (isSilent) {
|
||||
NotificationCompat.FOREGROUND_SERVICE_DEFERRED
|
||||
} else {
|
||||
NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
|
||||
}
|
||||
builder.setSilent(true)
|
||||
builder.setGroup(GROUP_ID)
|
||||
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
builder.priority = NotificationCompat.PRIORITY_DEFAULT
|
||||
builder.priority = if (isSilent) NotificationCompat.PRIORITY_MIN else NotificationCompat.PRIORITY_DEFAULT
|
||||
}
|
||||
|
||||
suspend fun create(state: DownloadState?): Notification = mutex.withLock {
|
||||
@@ -259,7 +265,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
||||
if (manga != null) {
|
||||
DetailsActivity.newIntent(context, manga)
|
||||
} else {
|
||||
MangaListActivity.newIntent(context, LocalMangaSource)
|
||||
MangaListActivity.newIntent(context, LocalMangaSource, null)
|
||||
},
|
||||
PendingIntent.FLAG_CANCEL_CURRENT,
|
||||
false,
|
||||
@@ -283,20 +289,30 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
private fun createChannels() {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(context.getString(R.string.downloads))
|
||||
.setVibrationEnabled(false)
|
||||
.setLightsEnabled(false)
|
||||
.setSound(null, null)
|
||||
.build()
|
||||
manager.createNotificationChannel(channel)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannelCompat.Builder(CHANNEL_ID_DEFAULT, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(context.getString(R.string.downloads))
|
||||
.setVibrationEnabled(false)
|
||||
.setLightsEnabled(false)
|
||||
.setSound(null, null)
|
||||
.build(),
|
||||
)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannelCompat.Builder(CHANNEL_ID_SILENT, NotificationManagerCompat.IMPORTANCE_MIN)
|
||||
.setName(context.getString(R.string.downloads_background))
|
||||
.setVibrationEnabled(false)
|
||||
.setLightsEnabled(false)
|
||||
.setSound(null, null)
|
||||
.setShowBadge(false)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
||||
fun create(uuid: UUID): DownloadNotificationFactory
|
||||
fun create(uuid: UUID, isSilent: Boolean): DownloadNotificationFactory
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.local.data.TempFileFilter
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
||||
import org.koitharu.kotatsu.local.domain.MangaLock
|
||||
@@ -104,7 +105,10 @@ class DownloadWorker @AssistedInject constructor(
|
||||
notificationFactoryFactory: DownloadNotificationFactory.Factory,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
private val notificationFactory = notificationFactoryFactory.create(params.id)
|
||||
private val notificationFactory = notificationFactoryFactory.create(
|
||||
uuid = params.id,
|
||||
isSilent = params.inputData.getBoolean(IS_SILENT, false),
|
||||
)
|
||||
private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
private val slowdownDispatcher = DownloadSlowdownDispatcher(mangaRepositoryFactory, SLOWDOWN_DELAY)
|
||||
|
||||
@@ -120,8 +124,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
setForeground(getForegroundInfo())
|
||||
val mangaId = inputData.getLong(MANGA_ID, 0L)
|
||||
val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure()
|
||||
lastPublishedState = DownloadState(manga, isIndeterminate = true)
|
||||
publishState(DownloadState(manga, isIndeterminate = true))
|
||||
publishState(DownloadState(manga = manga, isIndeterminate = true).also { lastPublishedState = it })
|
||||
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
|
||||
val downloadedIds = getDoneChapters(manga)
|
||||
return try {
|
||||
@@ -380,7 +383,9 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
val notification = notificationFactory.create(state)
|
||||
if (state.isFinalState) {
|
||||
notificationManager.notify(id.toString(), id.hashCode(), notification)
|
||||
if (!notificationFactory.isSilent) {
|
||||
notificationManager.notify(id.toString(), id.hashCode(), notification)
|
||||
}
|
||||
} else if (notificationThrottler.throttle()) {
|
||||
notificationManager.notify(id.hashCode(), notification)
|
||||
} else {
|
||||
@@ -426,10 +431,11 @@ class DownloadWorker @AssistedInject constructor(
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
suspend fun schedule(manga: Manga, chaptersIds: Collection<Long>?) {
|
||||
suspend fun schedule(manga: Manga, chaptersIds: Collection<Long>?, isSilent: Boolean) {
|
||||
dataRepository.storeManga(manga)
|
||||
val data = Data.Builder()
|
||||
.putLong(MANGA_ID, manga.id)
|
||||
.putBoolean(IS_SILENT, isSilent)
|
||||
if (!chaptersIds.isNullOrEmpty()) {
|
||||
data.putLongArray(CHAPTERS_IDS, chaptersIds.toLongArray())
|
||||
}
|
||||
@@ -549,6 +555,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
const val SLOWDOWN_DELAY = 200L
|
||||
const val MANGA_ID = "manga_id"
|
||||
const val CHAPTERS_IDS = "chapters"
|
||||
const val IS_SILENT = "silent"
|
||||
const val TAG = "download"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ 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
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
@@ -27,6 +26,7 @@ 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.core.util.ext.flattenLatest
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
@@ -49,14 +49,13 @@ class MangaSourcesRepository @Inject constructor(
|
||||
private val dao: MangaSourcesDao
|
||||
get() = db.getSourcesDao()
|
||||
|
||||
private val remoteSources = EnumSet.allOf(MangaParserSource::class.java).apply {
|
||||
if (!BuildConfig.DEBUG) {
|
||||
remove(MangaParserSource.DUMMY)
|
||||
}
|
||||
}
|
||||
|
||||
val allMangaSources: Set<MangaParserSource>
|
||||
get() = Collections.unmodifiableSet(remoteSources)
|
||||
val allMangaSources: Set<MangaParserSource> = Collections.unmodifiableSet(
|
||||
EnumSet.allOf(MangaParserSource::class.java).apply {
|
||||
if (!BuildConfig.DEBUG) {
|
||||
remove(MangaParserSource.DUMMY)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun getEnabledSources(): List<MangaSource> {
|
||||
assimilateNewSources()
|
||||
@@ -85,7 +84,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
|
||||
suspend fun getDisabledSources(): Set<MangaSource> {
|
||||
assimilateNewSources()
|
||||
val result = EnumSet.copyOf(remoteSources)
|
||||
val result = EnumSet.copyOf(allMangaSources)
|
||||
val enabled = dao.findAllEnabledNames()
|
||||
for (name in enabled) {
|
||||
val source = name.toMangaSourceOrNull() ?: continue
|
||||
@@ -168,7 +167,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
dao.observeEnabled(order).map {
|
||||
it.toSources(skipNsfw, order)
|
||||
}
|
||||
}.flatMapLatest { it }
|
||||
}.flattenLatest()
|
||||
.onStart { assimilateNewSources() }
|
||||
.combine(observeExternalSources()) { enabled, external ->
|
||||
val list = ArrayList<MangaSourceInfo>(enabled.size + external.size)
|
||||
@@ -181,7 +180,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
val result = ArrayList<Pair<MangaSource, Boolean>>(entities.size)
|
||||
for (entity in entities) {
|
||||
val source = entity.source.toMangaSourceOrNull() ?: continue
|
||||
if (source in remoteSources) {
|
||||
if (source in allMangaSources) {
|
||||
result.add(source to entity.isEnabled)
|
||||
}
|
||||
}
|
||||
@@ -198,7 +197,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
suspend fun setSourcesEnabledExclusive(sources: Set<MangaSource>) {
|
||||
db.withTransaction {
|
||||
assimilateNewSources()
|
||||
for (s in remoteSources) {
|
||||
for (s in allMangaSources) {
|
||||
dao.setEnabled(s.name, s in sources)
|
||||
}
|
||||
}
|
||||
@@ -221,7 +220,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
|
||||
fun observeHasNewSources(): Flow<Boolean> = observeIsNsfwDisabled().map { skipNsfw ->
|
||||
val sources = dao.findAllFromVersion(BuildConfig.VERSION_CODE).toSources(skipNsfw, null)
|
||||
sources.isNotEmpty() && sources.size != remoteSources.size
|
||||
sources.isNotEmpty() && sources.size != allMangaSources.size
|
||||
}.onStart { assimilateNewSources() }
|
||||
|
||||
fun observeHasNewSourcesForBadge(): Flow<Boolean> = combine(
|
||||
@@ -294,7 +293,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
|
||||
private suspend fun getNewSources(): MutableSet<out MangaSource> {
|
||||
val entities = dao.findAll()
|
||||
val result = EnumSet.copyOf(remoteSources)
|
||||
val result = EnumSet.copyOf(allMangaSources)
|
||||
for (e in entities) {
|
||||
result.remove(e.source.toMangaSourceOrNull() ?: continue)
|
||||
}
|
||||
@@ -360,7 +359,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
if (skipNsfwSources && source.isNsfw()) {
|
||||
continue
|
||||
}
|
||||
if (source in remoteSources) {
|
||||
if (source in allMangaSources) {
|
||||
result.add(
|
||||
MangaSourceInfo(
|
||||
mangaSource = source,
|
||||
|
||||
@@ -70,15 +70,14 @@ class ExploreRepository @Inject constructor(
|
||||
): List<Manga> = runCatchingCancellable {
|
||||
val repository = mangaRepositoryFactory.create(source)
|
||||
val order = repository.sortOrders.random()
|
||||
val availableTags = repository.getTags()
|
||||
val availableTags = repository.getFilterOptions().availableTags
|
||||
val tag = tags.firstNotNullOfOrNull { title ->
|
||||
availableTags.find { x -> x.title.almostEquals(title, 0.4f) }
|
||||
}
|
||||
val list = repository.getList(
|
||||
offset = 0,
|
||||
filter = MangaListFilter.Advanced.Builder(order)
|
||||
.tags(setOfNotNull(tag))
|
||||
.build(),
|
||||
order = order,
|
||||
filter = MangaListFilter(tags = setOfNotNull(tag))
|
||||
).asArrayList()
|
||||
if (settings.isSuggestionsExcludeNsfw) {
|
||||
list.removeAll { it.isNsfw }
|
||||
|
||||
@@ -19,7 +19,7 @@ class RecoverMangaUseCase @Inject constructor(
|
||||
return@runCatchingCancellable null
|
||||
}
|
||||
val repository = repositoryFactory.create(manga.source)
|
||||
val list = repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
|
||||
val list = repository.getList(offset = 0, null, MangaListFilter(query = manga.title))
|
||||
val newManga = list.find { x -> x.title == manga.title }?.let {
|
||||
repository.getDetails(it)
|
||||
} ?: return@runCatchingCancellable null
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -91,9 +92,7 @@ class ExploreFragment :
|
||||
checkNotNull(sourceSelectionController).attachToRecyclerView(this)
|
||||
}
|
||||
addMenuProvider(ExploreMenuProvider(binding.root.context))
|
||||
viewModel.content.observe(viewLifecycleOwner) {
|
||||
exploreAdapter?.items = it
|
||||
}
|
||||
viewModel.content.observe(viewLifecycleOwner, checkNotNull(exploreAdapter))
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
|
||||
viewModel.onOpenManga.observeEvent(viewLifecycleOwner, ::onOpenManga)
|
||||
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
|
||||
@@ -126,7 +125,7 @@ class ExploreFragment :
|
||||
|
||||
override fun onClick(v: View) {
|
||||
val intent = when (v.id) {
|
||||
R.id.button_local -> MangaListActivity.newIntent(v.context, LocalMangaSource)
|
||||
R.id.button_local -> MangaListActivity.newIntent(v.context, LocalMangaSource, null)
|
||||
R.id.button_bookmarks -> AllBookmarksActivity.newIntent(v.context)
|
||||
R.id.button_more -> SuggestionsActivity.newIntent(v.context)
|
||||
R.id.button_downloads -> Intent(v.context, DownloadsActivity::class.java)
|
||||
@@ -144,12 +143,16 @@ class ExploreFragment :
|
||||
if (sourceSelectionController?.onItemClick(item.id) == true) {
|
||||
return
|
||||
}
|
||||
val intent = MangaListActivity.newIntent(view.context, item.source)
|
||||
val intent = MangaListActivity.newIntent(view.context, item.source, null)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: MangaSourceItem, view: View): Boolean {
|
||||
return sourceSelectionController?.onItemLongClick(item.id) ?: false
|
||||
return sourceSelectionController?.onItemLongClick(view, item.id) ?: false
|
||||
}
|
||||
|
||||
override fun onItemContextClick(item: MangaSourceItem, view: View): Boolean {
|
||||
return sourceSelectionController?.onItemContextClick(view, item.id) ?: false
|
||||
}
|
||||
|
||||
override fun onRetryClick(error: Throwable) = Unit
|
||||
@@ -162,12 +165,16 @@ class ExploreFragment :
|
||||
viewBinding?.recyclerView?.invalidateItemDecorations()
|
||||
}
|
||||
|
||||
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.mode_source, menu)
|
||||
override fun onCreateActionMode(
|
||||
controller: ListSelectionController,
|
||||
menuInflater: MenuInflater,
|
||||
menu: Menu
|
||||
): Boolean {
|
||||
menuInflater.inflate(R.menu.mode_source, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean {
|
||||
val selectedSources = viewModel.sourcesSnapshot(controller.peekCheckedIds())
|
||||
val isSingleSelection = selectedSources.size == 1
|
||||
menu.findItem(R.id.action_settings).isVisible = isSingleSelection
|
||||
@@ -179,7 +186,7 @@ class ExploreFragment :
|
||||
return super.onPrepareActionMode(controller, mode, menu)
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
|
||||
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean {
|
||||
val selectedSources = viewModel.sourcesSnapshot(controller.peekCheckedIds())
|
||||
if (selectedSources.isEmpty()) {
|
||||
return false
|
||||
@@ -188,35 +195,35 @@ class ExploreFragment :
|
||||
R.id.action_settings -> {
|
||||
val source = selectedSources.singleOrNull() ?: return false
|
||||
startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), source))
|
||||
mode.finish()
|
||||
mode?.finish()
|
||||
}
|
||||
|
||||
R.id.action_disable -> {
|
||||
viewModel.disableSources(selectedSources)
|
||||
mode.finish()
|
||||
mode?.finish()
|
||||
}
|
||||
|
||||
R.id.action_delete -> {
|
||||
selectedSources.forEach {
|
||||
(it.mangaSource as? ExternalMangaSource)?.let { uninstallExternalSource(it) }
|
||||
}
|
||||
mode.finish()
|
||||
mode?.finish()
|
||||
}
|
||||
|
||||
R.id.action_shortcut -> {
|
||||
val source = selectedSources.singleOrNull() ?: return false
|
||||
viewModel.requestPinShortcut(source)
|
||||
mode.finish()
|
||||
mode?.finish()
|
||||
}
|
||||
|
||||
R.id.action_pin -> {
|
||||
viewModel.setSourcesPinned(selectedSources, isPinned = true)
|
||||
mode.finish()
|
||||
mode?.finish()
|
||||
}
|
||||
|
||||
R.id.action_unpin -> {
|
||||
viewModel.setSourcesPinned(selectedSources, isPinned = false)
|
||||
mode.finish()
|
||||
mode?.finish()
|
||||
}
|
||||
|
||||
else -> return false
|
||||
|
||||
@@ -20,7 +20,6 @@ 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
|
||||
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.setProgressIcon
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
@@ -117,13 +116,9 @@ fun exploreSourceListItemAD(
|
||||
on = { item, _, _ -> item is MangaSourceItem && !item.isGrid },
|
||||
) {
|
||||
|
||||
val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
|
||||
AdapterDelegateClickListenerAdapter(this, listener).attach(itemView)
|
||||
val iconPinned = ContextCompat.getDrawable(context, R.drawable.ic_pin_small)
|
||||
|
||||
binding.root.setOnClickListener(eventListener)
|
||||
binding.root.setOnLongClickListener(eventListener)
|
||||
binding.root.setOnContextClickListenerCompat(eventListener)
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.source.getTitle(context)
|
||||
binding.textViewTitle.drawableStart = if (item.source.isPinned) iconPinned else null
|
||||
@@ -154,13 +149,9 @@ fun exploreSourceGridItemAD(
|
||||
on = { item, _, _ -> item is MangaSourceItem && item.isGrid },
|
||||
) {
|
||||
|
||||
val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
|
||||
AdapterDelegateClickListenerAdapter(this, listener).attach(itemView)
|
||||
val iconPinned = ContextCompat.getDrawable(context, R.drawable.ic_pin_small)
|
||||
|
||||
binding.root.setOnClickListener(eventListener)
|
||||
binding.root.setOnLongClickListener(eventListener)
|
||||
binding.root.setOnContextClickListenerCompat(eventListener)
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.source.getTitle(context)
|
||||
binding.textViewTitle.drawableStart = if (item.source.isPinned) iconPinned else null
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.favourites.data
|
||||
|
||||
import android.database.DatabaseUtils.sqlEscapeString
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
@@ -13,6 +14,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import org.intellij.lang.annotations.Language
|
||||
import org.koitharu.kotatsu.core.db.MangaQueryBuilder
|
||||
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||
import org.koitharu.kotatsu.favourites.domain.model.Cover
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||
@@ -31,6 +33,10 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback {
|
||||
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit")
|
||||
abstract suspend fun findLast(limit: Int): List<FavouriteManga>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT manga.* FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.deleted_at = 0 AND (manga.title LIKE :query OR manga.alt_title LIKE :query) LIMIT :limit")
|
||||
abstract suspend fun search(query: String, limit: Int): List<MangaWithTags>
|
||||
|
||||
fun observeAll(
|
||||
order: ListSortOrder,
|
||||
filterOptions: Set<ListFilterOption>,
|
||||
@@ -120,6 +126,12 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback {
|
||||
@Query("SELECT COUNT(category_id) FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0")
|
||||
abstract suspend fun findCategoriesCount(mangaId: Long): Int
|
||||
|
||||
@Query("SELECT manga.source AS count FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id GROUP BY manga.source ORDER BY COUNT(manga.source) DESC LIMIT :limit")
|
||||
abstract suspend fun findPopularSources(limit: Int): List<String>
|
||||
|
||||
@Query("SELECT manga.source AS count FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.category_id = :categoryId GROUP BY manga.source ORDER BY COUNT(manga.source) DESC LIMIT :limit")
|
||||
abstract suspend fun findPopularSources(categoryId: Long, limit: Int): List<String>
|
||||
|
||||
/** INSERT **/
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
@@ -199,6 +211,8 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback {
|
||||
ListFilterOption.Macro.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = favourites.manga_id) > 0"
|
||||
ListFilterOption.Macro.NSFW -> "manga.nsfw = 1"
|
||||
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE favourites.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})"
|
||||
ListFilterOption.Downloaded -> "EXISTS(SELECT * FROM local_index WHERE local_index.manga_id = favourites.manga_id)"
|
||||
is ListFilterOption.Source -> "manga.source = ${sqlEscapeString(option.mangaSource.name)}"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package org.koitharu.kotatsu.favourites.domain
|
||||
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.domain.MangaListQuickFilter
|
||||
import javax.inject.Inject
|
||||
|
||||
class FavoritesListQuickFilter @Inject constructor(
|
||||
class FavoritesListQuickFilter @AssistedInject constructor(
|
||||
@Assisted private val categoryId: Long,
|
||||
private val settings: AppSettings,
|
||||
private val repository: FavouritesRepository,
|
||||
networkState: NetworkState,
|
||||
@@ -22,5 +25,14 @@ class FavoritesListQuickFilter @Inject constructor(
|
||||
add(ListFilterOption.Macro.NEW_CHAPTERS)
|
||||
}
|
||||
add(ListFilterOption.Macro.COMPLETED)
|
||||
repository.findPopularSources(categoryId, 3).mapTo(this) {
|
||||
ListFilterOption.Source(it)
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
||||
fun create(categoryId: Long): FavoritesListQuickFilter
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,23 +10,27 @@ import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.toEntities
|
||||
import org.koitharu.kotatsu.core.db.entity.toEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.toMangaList
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.model.toMangaSources
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
|
||||
import org.koitharu.kotatsu.core.util.ext.mapItems
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
|
||||
import org.koitharu.kotatsu.favourites.data.toManga
|
||||
import org.koitharu.kotatsu.favourites.data.toMangaList
|
||||
import org.koitharu.kotatsu.favourites.domain.model.Cover
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class FavouritesRepository @Inject constructor(
|
||||
private val db: MangaDatabase,
|
||||
private val localObserver: LocalFavoritesObserver,
|
||||
) {
|
||||
|
||||
suspend fun getAllManga(): List<Manga> {
|
||||
@@ -39,9 +43,17 @@ class FavouritesRepository @Inject constructor(
|
||||
return entities.toMangaList()
|
||||
}
|
||||
|
||||
suspend fun search(query: String, limit: Int): List<Manga> {
|
||||
val entities = db.getFavouritesDao().search("%$query%", limit)
|
||||
return entities.toMangaList().sortedBy { it.title.levenshteinDistance(query) }
|
||||
}
|
||||
|
||||
fun observeAll(order: ListSortOrder, filterOptions: Set<ListFilterOption>, limit: Int): Flow<List<Manga>> {
|
||||
if (ListFilterOption.Downloaded in filterOptions) {
|
||||
return localObserver.observeAll(order, filterOptions, limit)
|
||||
}
|
||||
return db.getFavouritesDao().observeAll(order, filterOptions, limit)
|
||||
.mapItems { it.toManga() }
|
||||
.map { it.toMangaList() }
|
||||
}
|
||||
|
||||
suspend fun getManga(categoryId: Long): List<Manga> {
|
||||
@@ -55,8 +67,11 @@ class FavouritesRepository @Inject constructor(
|
||||
filterOptions: Set<ListFilterOption>,
|
||||
limit: Int
|
||||
): Flow<List<Manga>> {
|
||||
if (ListFilterOption.Downloaded in filterOptions) {
|
||||
return localObserver.observeAll(categoryId, order, filterOptions, limit)
|
||||
}
|
||||
return db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit)
|
||||
.mapItems { it.toManga() }
|
||||
.map { it.toMangaList() }
|
||||
}
|
||||
|
||||
fun observeAll(categoryId: Long, filterOptions: Set<ListFilterOption>, limit: Int): Flow<List<Manga>> {
|
||||
@@ -129,6 +144,16 @@ class FavouritesRepository @Inject constructor(
|
||||
return db.getFavouritesDao().findCategoriesIds(mangaId).toSet()
|
||||
}
|
||||
|
||||
suspend fun findPopularSources(categoryId: Long, limit: Int): List<MangaSource> {
|
||||
return db.getFavouritesDao().run {
|
||||
if (categoryId == 0L) {
|
||||
findPopularSources(limit)
|
||||
} else {
|
||||
findPopularSources(categoryId, limit)
|
||||
}
|
||||
}.toMangaSources()
|
||||
}
|
||||
|
||||
suspend fun createCategory(
|
||||
title: String,
|
||||
sortOrder: ListSortOrder,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.koitharu.kotatsu.favourites.domain
|
||||
|
||||
import dagger.Reusable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteManga
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
import org.koitharu.kotatsu.local.domain.LocalObserveMapper
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class LocalFavoritesObserver @Inject constructor(
|
||||
localMangaIndex: LocalMangaIndex,
|
||||
private val db: MangaDatabase,
|
||||
) : LocalObserveMapper<FavouriteManga, Manga>(localMangaIndex) {
|
||||
|
||||
fun observeAll(
|
||||
order: ListSortOrder,
|
||||
filterOptions: Set<ListFilterOption>,
|
||||
limit: Int
|
||||
): Flow<List<Manga>> = db.getFavouritesDao().observeAll(order, filterOptions, limit).mapToLocal()
|
||||
|
||||
fun observeAll(
|
||||
categoryId: Long,
|
||||
order: ListSortOrder,
|
||||
filterOptions: Set<ListFilterOption>,
|
||||
limit: Int
|
||||
): Flow<List<Manga>> = db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit).mapToLocal()
|
||||
|
||||
override fun toManga(e: FavouriteManga) = e.manga.toManga(e.tags.toMangaTags())
|
||||
|
||||
override fun toResult(e: FavouriteManga, manga: Manga) = manga
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.favourites.ui.categories
|
||||
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -17,12 +18,16 @@ class CategoriesSelectionCallback(
|
||||
recyclerView.invalidateItemDecorations()
|
||||
}
|
||||
|
||||
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.mode_category, menu)
|
||||
override fun onCreateActionMode(
|
||||
controller: ListSelectionController,
|
||||
menuInflater: MenuInflater,
|
||||
menu: Menu
|
||||
): Boolean {
|
||||
menuInflater.inflate(R.menu.mode_category, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean {
|
||||
val categories = viewModel.getCategories(controller.peekCheckedIds())
|
||||
var canShow = categories.isNotEmpty()
|
||||
var canHide = canShow
|
||||
@@ -35,11 +40,11 @@ class CategoriesSelectionCallback(
|
||||
}
|
||||
menu.findItem(R.id.action_show)?.isVisible = canShow
|
||||
menu.findItem(R.id.action_hide)?.isVisible = canHide
|
||||
mode.title = controller.count.toString()
|
||||
mode?.title = controller.count.toString()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
|
||||
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
/*R.id.action_view -> {
|
||||
val id = controller.peekCheckedIds().singleOrNull() ?: return false
|
||||
@@ -53,13 +58,13 @@ class CategoriesSelectionCallback(
|
||||
|
||||
R.id.action_show -> {
|
||||
viewModel.setIsVisible(controller.snapshot(), true)
|
||||
mode.finish()
|
||||
mode?.finish()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_hide -> {
|
||||
viewModel.setIsVisible(controller.snapshot(), false)
|
||||
mode.finish()
|
||||
mode?.finish()
|
||||
true
|
||||
}
|
||||
|
||||
@@ -72,7 +77,7 @@ class CategoriesSelectionCallback(
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmDeleteCategories(ids: Set<Long>, mode: ActionMode) {
|
||||
private fun confirmDeleteCategories(ids: Set<Long>, mode: ActionMode?) {
|
||||
buildAlertDialog(recyclerView.context, isCentered = true) {
|
||||
setMessage(R.string.categories_delete_confirm)
|
||||
setTitle(R.string.remove_category)
|
||||
@@ -80,7 +85,7 @@ class CategoriesSelectionCallback(
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
setPositiveButton(R.string.remove) { _, _ ->
|
||||
viewModel.deleteCategories(ids)
|
||||
mode.finish()
|
||||
mode?.finish()
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
@@ -98,7 +98,11 @@ class FavouriteCategoriesActivity :
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: FavouriteCategory?, view: View): Boolean {
|
||||
return item != null && selectionController.onItemLongClick(item.id)
|
||||
return item != null && selectionController.onItemLongClick(view, item.id)
|
||||
}
|
||||
|
||||
override fun onItemContextClick(item: FavouriteCategory?, view: View): Boolean {
|
||||
return item != null && selectionController.onItemContextClick(view, item.id)
|
||||
}
|
||||
|
||||
override fun onSupportActionModeStarted(mode: ActionMode) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user