Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8fa0e33f1 | ||
|
|
97bc638f5f | ||
|
|
064c0ae425 | ||
|
|
ae7aa52177 | ||
|
|
6edda72d61 | ||
|
|
2f58f32bdd | ||
|
|
0b821db046 | ||
|
|
36472998ee | ||
|
|
c2e7325876 | ||
|
|
28a4a3849c | ||
|
|
6e9c934912 | ||
|
|
675ef0e629 | ||
|
|
484914b2dc | ||
|
|
ee85ef50f4 | ||
|
|
dcee5542c5 | ||
|
|
9b3ce4d849 | ||
|
|
5ab7e586f3 | ||
|
|
9f5d4ed52c | ||
|
|
c3ca734005 | ||
|
|
a158a488f2 | ||
|
|
6048cb917e | ||
|
|
81aac0d431 | ||
|
|
dfb50fbddc | ||
|
|
1f03e0a84b | ||
|
|
77e393ae48 | ||
|
|
77bb5c2fcd | ||
|
|
475a4904a9 | ||
|
|
cf43b8ebda | ||
|
|
f34096af98 | ||
|
|
d60ff2a052 | ||
|
|
59d4953554 | ||
|
|
f76052b1d6 | ||
|
|
26e59b0953 | ||
|
|
9ee1164f08 | ||
|
|
cfc3823593 | ||
|
|
8407a414c5 | ||
|
|
a379604974 | ||
|
|
c01d80f7da | ||
|
|
7533dce0d2 | ||
|
|
9f1e97fd54 | ||
|
|
382a73310c | ||
|
|
5eeab7fd08 | ||
|
|
bc54e7cfba | ||
|
|
4502ffb6d2 | ||
|
|
b6f9ce824e | ||
|
|
d33081c1c7 | ||
|
|
76c08535d6 | ||
|
|
b55fef67e1 | ||
|
|
56798677d5 | ||
|
|
ff30b9c225 | ||
|
|
5c3293ec44 | ||
|
|
1b17605e0e | ||
|
|
ba4e4dcf56 | ||
|
|
b35d5d4779 | ||
|
|
124f31ebe1 | ||
|
|
173087ee19 | ||
|
|
8d7bad97de | ||
|
|
188fbfbb95 | ||
|
|
3498a54bdf | ||
|
|
18169c2355 | ||
|
|
87beb9442f | ||
|
|
e642d54929 | ||
|
|
59ce5d5e67 | ||
|
|
58d5237692 |
@@ -16,8 +16,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
versionCode = 645
|
||||
versionName = '7.1.2'
|
||||
versionCode = 651
|
||||
versionName = '7.3'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
@@ -82,7 +82,7 @@ afterEvaluate {
|
||||
}
|
||||
dependencies {
|
||||
//noinspection GradleDependency
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:26be293f24') {
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:74b8aaa94e') {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
@@ -90,15 +90,15 @@ dependencies {
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
implementation 'androidx.activity:activity-ktx:1.9.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.7.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.8.1'
|
||||
implementation 'androidx.transition:transition-ktx:1.5.0'
|
||||
implementation 'androidx.collection:collection-ktx:1.4.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.3'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.3'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||
@@ -106,7 +106,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.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.3'
|
||||
implementation 'androidx.webkit:webkit:1.11.0'
|
||||
|
||||
implementation 'androidx.work:work-runtime:2.9.0'
|
||||
@@ -136,7 +136,7 @@ dependencies {
|
||||
|
||||
implementation 'io.coil-kt:coil-base:2.6.0'
|
||||
implementation 'io.coil-kt:coil-svg:2.6.0'
|
||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9'
|
||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:882bc0620c'
|
||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||
implementation 'io.noties.markwon:core:4.6.2'
|
||||
|
||||
@@ -154,10 +154,10 @@ dependencies {
|
||||
testImplementation 'org.json:json:20240303'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
|
||||
|
||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
|
||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
|
||||
androidTestImplementation 'androidx.test:runner:1.6.1'
|
||||
androidTestImplementation 'androidx.test:rules:1.6.1'
|
||||
androidTestImplementation 'androidx.test:core-ktx:1.6.1'
|
||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
|
||||
|
||||
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
|
||||
|
||||
|
||||
@@ -12,135 +12,184 @@ import org.koitharu.kotatsu.history.data.toMangaHistory
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
|
||||
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||
import javax.inject.Inject
|
||||
|
||||
class MigrateUseCase @Inject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
private val database: MangaDatabase,
|
||||
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(oldManga: Manga, newManga: Manga) {
|
||||
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
|
||||
runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||
}.getOrDefault(oldManga)
|
||||
} else {
|
||||
oldManga
|
||||
}
|
||||
val newDetails = if (newManga.chapters.isNullOrEmpty()) {
|
||||
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
||||
} else {
|
||||
newManga
|
||||
}
|
||||
mangaDataRepository.storeManga(newDetails)
|
||||
database.withTransaction {
|
||||
// replace favorites
|
||||
val favoritesDao = database.getFavouritesDao()
|
||||
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
|
||||
if (oldFavourites.isNotEmpty()) {
|
||||
favoritesDao.delete(oldManga.id)
|
||||
for (f in oldFavourites) {
|
||||
val e = f.copy(
|
||||
mangaId = newManga.id,
|
||||
class MigrateUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
private val database: MangaDatabase,
|
||||
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
||||
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
oldManga: Manga,
|
||||
newManga: Manga,
|
||||
) {
|
||||
val oldDetails =
|
||||
if (oldManga.chapters.isNullOrEmpty()) {
|
||||
runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||
}.getOrDefault(oldManga)
|
||||
} else {
|
||||
oldManga
|
||||
}
|
||||
val newDetails =
|
||||
if (newManga.chapters.isNullOrEmpty()) {
|
||||
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
||||
} else {
|
||||
newManga
|
||||
}
|
||||
mangaDataRepository.storeManga(newDetails)
|
||||
database.withTransaction {
|
||||
// replace favorites
|
||||
val favoritesDao = database.getFavouritesDao()
|
||||
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
|
||||
if (oldFavourites.isNotEmpty()) {
|
||||
favoritesDao.delete(oldManga.id)
|
||||
for (f in oldFavourites) {
|
||||
val e =
|
||||
f.copy(
|
||||
mangaId = newManga.id,
|
||||
)
|
||||
favoritesDao.upsert(e)
|
||||
}
|
||||
}
|
||||
// replace history
|
||||
val historyDao = database.getHistoryDao()
|
||||
val oldHistory = historyDao.find(oldDetails.id)
|
||||
val newHistory =
|
||||
if (oldHistory != null) {
|
||||
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
|
||||
historyDao.delete(oldDetails.id)
|
||||
historyDao.upsert(newHistory)
|
||||
newHistory
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// track
|
||||
val tracksDao = database.getTracksDao()
|
||||
val oldTrack = tracksDao.find(oldDetails.id)
|
||||
if (oldTrack != null) {
|
||||
val lastChapter = newDetails.chapters?.lastOrNull()
|
||||
val newTrack =
|
||||
TrackEntity(
|
||||
mangaId = newDetails.id,
|
||||
lastChapterId = lastChapter?.id ?: 0L,
|
||||
newChapters = 0,
|
||||
lastCheckTime = System.currentTimeMillis(),
|
||||
lastChapterDate = lastChapter?.uploadDate ?: 0L,
|
||||
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
|
||||
lastError = null,
|
||||
)
|
||||
tracksDao.delete(oldDetails.id)
|
||||
tracksDao.upsert(newTrack)
|
||||
}
|
||||
// scrobbling
|
||||
for (scrobbler in scrobblers) {
|
||||
if (!scrobbler.isEnabled) {
|
||||
continue
|
||||
}
|
||||
val prevInfo = scrobbler.getScrobblingInfoOrNull(oldDetails.id) ?: continue
|
||||
scrobbler.unregisterScrobbling(oldDetails.id)
|
||||
scrobbler.linkManga(newDetails.id, prevInfo.targetId)
|
||||
scrobbler.updateScrobblingInfo(
|
||||
mangaId = newDetails.id,
|
||||
rating = prevInfo.rating,
|
||||
status =
|
||||
prevInfo.status ?: when {
|
||||
newHistory == null -> ScrobblingStatus.PLANNED
|
||||
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
|
||||
else -> ScrobblingStatus.READING
|
||||
},
|
||||
comment = prevInfo.comment,
|
||||
)
|
||||
favoritesDao.upsert(e)
|
||||
if (newHistory != null) {
|
||||
scrobbler.scrobble(
|
||||
manga = newDetails,
|
||||
chapterId = newHistory.chapterId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// replace history
|
||||
val historyDao = database.getHistoryDao()
|
||||
val oldHistory = historyDao.find(oldDetails.id)
|
||||
if (oldHistory != null) {
|
||||
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
|
||||
historyDao.delete(oldDetails.id)
|
||||
historyDao.upsert(newHistory)
|
||||
}
|
||||
// track
|
||||
val tracksDao = database.getTracksDao()
|
||||
val oldTrack = tracksDao.find(oldDetails.id)
|
||||
if (oldTrack != null) {
|
||||
val lastChapter = newDetails.chapters?.lastOrNull()
|
||||
val newTrack = TrackEntity(
|
||||
mangaId = newDetails.id,
|
||||
lastChapterId = lastChapter?.id ?: 0L,
|
||||
newChapters = 0,
|
||||
lastCheckTime = System.currentTimeMillis(),
|
||||
lastChapterDate = lastChapter?.uploadDate ?: 0L,
|
||||
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
|
||||
)
|
||||
tracksDao.delete(oldDetails.id)
|
||||
tracksDao.upsert(newTrack)
|
||||
}
|
||||
progressUpdateUseCase(newManga)
|
||||
}
|
||||
progressUpdateUseCase(newManga)
|
||||
}
|
||||
|
||||
private fun makeNewHistory(
|
||||
oldManga: Manga,
|
||||
newManga: Manga,
|
||||
history: HistoryEntity,
|
||||
): HistoryEntity {
|
||||
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
||||
val branch = newManga.getPreferredBranch(null)
|
||||
val chapters = checkNotNull(newManga.getChapters(branch))
|
||||
val currentChapter = if (history.percent in 0f..1f) {
|
||||
chapters[(chapters.lastIndex * history.percent).toInt()]
|
||||
} else {
|
||||
chapters.first()
|
||||
private fun makeNewHistory(
|
||||
oldManga: Manga,
|
||||
newManga: Manga,
|
||||
history: HistoryEntity,
|
||||
): HistoryEntity {
|
||||
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
||||
val branch = newManga.getPreferredBranch(null)
|
||||
val chapters = checkNotNull(newManga.getChapters(branch))
|
||||
val currentChapter =
|
||||
if (history.percent in 0f..1f) {
|
||||
chapters[(chapters.lastIndex * history.percent).toInt()]
|
||||
} else {
|
||||
chapters.first()
|
||||
}
|
||||
return HistoryEntity(
|
||||
mangaId = newManga.id,
|
||||
createdAt = history.createdAt,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
chapterId = currentChapter.id,
|
||||
page = history.page,
|
||||
scroll = history.scroll,
|
||||
percent = history.percent,
|
||||
deletedAt = 0,
|
||||
chaptersCount = chapters.size,
|
||||
)
|
||||
}
|
||||
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
|
||||
val oldChapters = checkNotNull(oldManga.getChapters(branch))
|
||||
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
|
||||
if (index < 0) {
|
||||
index =
|
||||
if (history.percent in 0f..1f) {
|
||||
(oldChapters.lastIndex * history.percent).toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
|
||||
val newBranch =
|
||||
if (newChapters.containsKey(branch)) {
|
||||
branch
|
||||
} else {
|
||||
newManga.getPreferredBranch(null)
|
||||
}
|
||||
val newChapterId =
|
||||
checkNotNull(newChapters[newBranch])
|
||||
.let {
|
||||
val oldChapter = oldChapters[index]
|
||||
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
|
||||
}.id
|
||||
|
||||
return HistoryEntity(
|
||||
mangaId = newManga.id,
|
||||
createdAt = history.createdAt,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
chapterId = currentChapter.id,
|
||||
chapterId = newChapterId,
|
||||
page = history.page,
|
||||
scroll = history.scroll,
|
||||
percent = history.percent,
|
||||
percent = PROGRESS_NONE,
|
||||
deletedAt = 0,
|
||||
chaptersCount = chapters.size,
|
||||
chaptersCount = checkNotNull(newChapters[newBranch]).size,
|
||||
)
|
||||
}
|
||||
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
|
||||
val oldChapters = checkNotNull(oldManga.getChapters(branch))
|
||||
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
|
||||
if (index < 0) {
|
||||
index = if (history.percent in 0f..1f) {
|
||||
(oldChapters.lastIndex * history.percent).toInt()
|
||||
|
||||
private fun List<MangaChapter>.findByNumber(
|
||||
volume: Int,
|
||||
number: Float,
|
||||
): MangaChapter? =
|
||||
if (number <= 0f) {
|
||||
null
|
||||
} else {
|
||||
0
|
||||
firstOrNull { it.volume == volume && it.number == number }
|
||||
}
|
||||
}
|
||||
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
|
||||
val newBranch = if (newChapters.containsKey(branch)) {
|
||||
branch
|
||||
} else {
|
||||
newManga.getPreferredBranch(null)
|
||||
}
|
||||
val newChapterId = checkNotNull(newChapters[newBranch]).let {
|
||||
val oldChapter = oldChapters[index]
|
||||
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
|
||||
}.id
|
||||
|
||||
return HistoryEntity(
|
||||
mangaId = newManga.id,
|
||||
createdAt = history.createdAt,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
chapterId = newChapterId,
|
||||
page = history.page,
|
||||
scroll = history.scroll,
|
||||
percent = PROGRESS_NONE,
|
||||
deletedAt = 0,
|
||||
chaptersCount = checkNotNull(newChapters[newBranch]).size,
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<MangaChapter>.findByNumber(volume: Int, number: Float): MangaChapter? {
|
||||
return if (number <= 0f) {
|
||||
null
|
||||
} else {
|
||||
firstOrNull { it.volume == volume && it.number == number }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
||||
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
|
||||
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
|
||||
import org.koitharu.kotatsu.settings.backup.BackupObserver
|
||||
@@ -152,10 +153,12 @@ interface AppModule {
|
||||
appProtectHelper: AppProtectHelper,
|
||||
activityRecreationHandle: ActivityRecreationHandle,
|
||||
acraScreenLogger: AcraScreenLogger,
|
||||
screenshotPolicyHelper: ScreenshotPolicyHelper,
|
||||
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
|
||||
appProtectHelper,
|
||||
activityRecreationHandle,
|
||||
acraScreenLogger,
|
||||
screenshotPolicyHelper,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
||||
|
||||
class JsonDeserializer(private val json: JSONObject) {
|
||||
@@ -84,6 +85,9 @@ class JsonDeserializer(private val json: JSONObject) {
|
||||
source = json.getString("source"),
|
||||
isEnabled = json.getBoolean("enabled"),
|
||||
sortKey = json.getInt("sort_key"),
|
||||
addedIn = json.getIntOrDefault("added_in", 0),
|
||||
lastUsedAt = json.getLongOrDefault("used_at", 0L),
|
||||
isPinned = json.getBooleanOrDefault("pinned", false),
|
||||
)
|
||||
|
||||
fun toMap(): Map<String, Any?> {
|
||||
|
||||
@@ -89,6 +89,9 @@ class JsonSerializer private constructor(private val json: JSONObject) {
|
||||
put("source", e.source)
|
||||
put("enabled", e.isEnabled)
|
||||
put("sort_key", e.sortKey)
|
||||
put("added_in", e.addedIn)
|
||||
put("used_at", e.lastUsedAt)
|
||||
put("pinned", e.isPinned)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ import org.koitharu.kotatsu.core.db.migrations.Migration17To18
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration18To19
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration19To20
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration21To22
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
||||
@@ -58,7 +60,7 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TracksDao
|
||||
|
||||
const val DATABASE_VERSION = 20
|
||||
const val DATABASE_VERSION = 22
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
@@ -118,6 +120,8 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||
Migration17To18(),
|
||||
Migration18To19(),
|
||||
Migration19To20(),
|
||||
Migration20To21(),
|
||||
Migration21To22(),
|
||||
)
|
||||
|
||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||
|
||||
@@ -11,19 +11,26 @@ import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.intellij.lang.annotations.Language
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
||||
|
||||
@Dao
|
||||
abstract class MangaSourcesDao {
|
||||
|
||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
||||
@Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key")
|
||||
abstract suspend fun findAll(): List<MangaSourceEntity>
|
||||
|
||||
@Query("SELECT source FROM sources WHERE enabled = 1")
|
||||
abstract suspend fun findAllEnabledNames(): List<String>
|
||||
|
||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
||||
@Query("SELECT * FROM sources WHERE added_in >= :version")
|
||||
abstract suspend fun findAllFromVersion(version: Int): List<MangaSourceEntity>
|
||||
|
||||
@Query("SELECT * FROM sources ORDER BY used_at DESC LIMIT :limit")
|
||||
abstract suspend fun findLastUsed(limit: Int): List<MangaSourceEntity>
|
||||
|
||||
@Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key")
|
||||
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
||||
|
||||
@Query("SELECT enabled FROM sources WHERE source = :source")
|
||||
@@ -38,6 +45,12 @@ abstract class MangaSourcesDao {
|
||||
@Query("UPDATE sources SET sort_key = :sortKey WHERE source = :source")
|
||||
abstract suspend fun setSortKey(source: String, sortKey: Int)
|
||||
|
||||
@Query("UPDATE sources SET used_at = :value WHERE source = :source")
|
||||
abstract suspend fun setLastUsed(source: String, value: Long)
|
||||
|
||||
@Query("UPDATE sources SET pinned = :isPinned WHERE source = :source")
|
||||
abstract suspend fun setPinned(source: String, isPinned: Boolean)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
@Transaction
|
||||
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
|
||||
@@ -45,11 +58,14 @@ abstract class MangaSourcesDao {
|
||||
@Upsert
|
||||
abstract suspend fun upsert(entry: MangaSourceEntity)
|
||||
|
||||
@Query("SELECT * FROM sources WHERE pinned = 1")
|
||||
abstract suspend fun findAllPinned(): List<MangaSourceEntity>
|
||||
|
||||
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
|
||||
val orderBy = getOrderBy(order)
|
||||
|
||||
@Language("RoomSql")
|
||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
|
||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
|
||||
return observeImpl(query)
|
||||
}
|
||||
|
||||
@@ -57,7 +73,7 @@ abstract class MangaSourcesDao {
|
||||
val orderBy = getOrderBy(order)
|
||||
|
||||
@Language("RoomSql")
|
||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
|
||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
|
||||
return findAllImpl(query)
|
||||
}
|
||||
|
||||
@@ -68,6 +84,9 @@ abstract class MangaSourcesDao {
|
||||
source = source,
|
||||
isEnabled = isEnabled,
|
||||
sortKey = getMaxSortKey() + 1,
|
||||
addedIn = BuildConfig.VERSION_CODE,
|
||||
lastUsedAt = 0,
|
||||
isPinned = false,
|
||||
)
|
||||
upsert(entity)
|
||||
}
|
||||
@@ -86,5 +105,6 @@ abstract class MangaSourcesDao {
|
||||
SourcesSortOrder.ALPHABETIC -> "source ASC"
|
||||
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
|
||||
SourcesSortOrder.MANUAL -> "sort_key ASC"
|
||||
SourcesSortOrder.LAST_USED -> "used_at DESC"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,7 @@ data class MangaSourceEntity(
|
||||
val source: String,
|
||||
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
|
||||
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
|
||||
@ColumnInfo(name = "added_in") val addedIn: Int,
|
||||
@ColumnInfo(name = "used_at") val lastUsedAt: Long,
|
||||
@ColumnInfo(name = "pinned") val isPinned: Boolean,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration20To21 : Migration(20, 21) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE tracks ADD COLUMN `last_error` TEXT DEFAULT NULL")
|
||||
db.execSQL("ALTER TABLE sources ADD COLUMN `added_in` INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration21To22 : Migration(21, 22) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE sources ADD COLUMN `used_at` INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE sources ADD COLUMN `pinned` INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
@@ -15,15 +15,13 @@ import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import java.util.Locale
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
fun MangaSource(name: String): MangaSource {
|
||||
MangaSource.entries.forEach {
|
||||
if (it.name == name) return it
|
||||
}
|
||||
return MangaSource.DUMMY
|
||||
return MangaSource.UNKNOWN
|
||||
}
|
||||
|
||||
fun MangaSource.isNsfw() = contentType == ContentType.HENTAI
|
||||
@@ -39,7 +37,7 @@ val ContentType.titleResId
|
||||
|
||||
fun MangaSource.getSummary(context: Context): String {
|
||||
val type = context.getString(contentType.titleResId)
|
||||
val locale = locale?.toLocale().getDisplayName(context)
|
||||
val locale = locale.toLocale().getDisplayName(context)
|
||||
return context.getString(R.string.source_summary_pattern, type, locale)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ data class ParcelableChapter(
|
||||
scanlator = parcel.readString(),
|
||||
uploadDate = parcel.readLong(),
|
||||
branch = parcel.readString(),
|
||||
source = parcel.readSerializableCompat() ?: MangaSource.DUMMY,
|
||||
source = parcel.readSerializableCompat() ?: MangaSource.UNKNOWN,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ class DoHManager(
|
||||
).build()
|
||||
|
||||
DoHProvider.ZERO_MS -> DnsOverHttps.Builder().client(bootstrapClient)
|
||||
.url("https://0ms.dev/dns-query".toHttpUrl())
|
||||
.url("https://2ca4h4crra.cloudflare-gateway.com/dns-query".toHttpUrl())
|
||||
.resolvePublicAddresses(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.util.EnumSet
|
||||
|
||||
/**
|
||||
* This parser is just for parser development, it should not be used in releases
|
||||
*/
|
||||
class EmptyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
|
||||
|
||||
override val configKeyDomain: ConfigKey.Domain
|
||||
get() = ConfigKey.Domain("localhost")
|
||||
|
||||
override val availableSortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
|
||||
|
||||
override suspend fun getAvailableTags(): Set<MangaTag> = stub(null)
|
||||
|
||||
override suspend fun getRelatedManga(seed: Manga): List<Manga> = stub(seed)
|
||||
|
||||
private fun stub(manga: Manga?): Nothing {
|
||||
throw UnsupportedSourceException("This manga source is not supported", manga)
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ class MangaLinkResolver @Inject constructor(
|
||||
require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" }
|
||||
val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" }
|
||||
val source = MangaSource(sourceName)
|
||||
require(source != MangaSource.DUMMY) { "Manga source $sourceName is not supported" }
|
||||
require(source != MangaSource.UNKNOWN) { "Manga source $sourceName is not supported" }
|
||||
val repo = repositoryFactory.create(source)
|
||||
return repo.findExact(
|
||||
url = uri.getQueryParameter("url"),
|
||||
|
||||
@@ -5,9 +5,9 @@ import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaParser {
|
||||
return if (source == MangaSource.DUMMY) {
|
||||
DummyParser(loaderContext)
|
||||
} else {
|
||||
loaderContext.newParserInstance(source)
|
||||
return when (source) {
|
||||
MangaSource.UNKNOWN -> EmptyParser(loaderContext)
|
||||
MangaSource.DUMMY -> DummyParser(loaderContext)
|
||||
else -> loaderContext.newParserInstance(source)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
|
||||
import java.io.File
|
||||
import java.net.Proxy
|
||||
import java.util.EnumSet
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -290,17 +289,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getBoolean(KEY_SOURCES_GRID, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
|
||||
|
||||
val isNewSourcesTipEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_SOURCES_NEW, true)
|
||||
var sourcesVersion: Int
|
||||
get() = prefs.getInt(KEY_SOURCES_VERSION, 0)
|
||||
set(value) = prefs.edit { putInt(KEY_SOURCES_VERSION, value) }
|
||||
|
||||
val isPagesNumbersEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
|
||||
|
||||
val screenshotsPolicy: ScreenshotsPolicy
|
||||
get() = runCatching {
|
||||
val key = prefs.getString(KEY_SCREENSHOTS_POLICY, null)?.uppercase(Locale.ROOT)
|
||||
if (key == null) ScreenshotsPolicy.ALLOW else ScreenshotsPolicy.valueOf(key)
|
||||
}.getOrDefault(ScreenshotsPolicy.ALLOW)
|
||||
get() = prefs.getEnumValue(KEY_SCREENSHOTS_POLICY, ScreenshotsPolicy.ALLOW)
|
||||
|
||||
var userSpecifiedMangaDirectories: Set<File>
|
||||
get() {
|
||||
@@ -487,6 +484,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isAutoLocalChaptersCleanupEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_CHAPTERS_CLEAR_AUTO, false)
|
||||
|
||||
fun isPagesCropEnabled(mode: ReaderMode): Boolean {
|
||||
val rawValue = prefs.getStringSet(KEY_READER_CROP, emptySet())
|
||||
if (rawValue.isNullOrEmpty()) {
|
||||
return false
|
||||
}
|
||||
val needle = if (mode == ReaderMode.WEBTOON) READER_CROP_WEBTOON else READER_CROP_PAGED
|
||||
return needle.toString() in rawValue
|
||||
}
|
||||
|
||||
fun isTipEnabled(tip: String): Boolean {
|
||||
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
|
||||
}
|
||||
@@ -599,6 +605,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_READER_ANIMATION = "reader_animation2"
|
||||
const val KEY_READER_MODE = "reader_mode"
|
||||
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
|
||||
const val KEY_READER_CROP = "reader_crop"
|
||||
const val KEY_APP_PASSWORD = "app_password"
|
||||
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
|
||||
const val KEY_PROTECT_APP = "protect_app"
|
||||
@@ -653,7 +660,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_APP_LOCALE = "app_locale"
|
||||
const val KEY_LOGGING_ENABLED = "logging"
|
||||
const val KEY_SOURCES_GRID = "sources_grid"
|
||||
const val KEY_SOURCES_NEW = "sources_new"
|
||||
const val KEY_UPDATES_UNSTABLE = "updates_unstable"
|
||||
const val KEY_TIPS_CLOSED = "tips_closed"
|
||||
const val KEY_SSL_BYPASS = "ssl_bypass"
|
||||
@@ -689,6 +695,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_STATS_ENABLED = "stats_on"
|
||||
const val KEY_FEED_HEADER = "feed_header"
|
||||
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
|
||||
const val KEY_SOURCES_VERSION = "sources_version"
|
||||
|
||||
// keys for non-persistent preferences
|
||||
const val KEY_APP_VERSION = "app_version"
|
||||
@@ -700,5 +707,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
|
||||
// old keys are for migration only
|
||||
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"
|
||||
|
||||
// values
|
||||
private const val READER_CROP_PAGED = 1
|
||||
private const val READER_CROP_WEBTOON = 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@ package org.koitharu.kotatsu.core.prefs
|
||||
enum class ScreenshotsPolicy {
|
||||
|
||||
// Do not rename this
|
||||
ALLOW, BLOCK_NSFW, BLOCK_ALL;
|
||||
}
|
||||
ALLOW, BLOCK_NSFW, BLOCK_INCOGNITO, BLOCK_ALL;
|
||||
}
|
||||
|
||||
@@ -12,5 +12,6 @@ enum class SearchSuggestionType(
|
||||
QUERIES_SUGGEST(R.string.suggested_queries),
|
||||
MANGA(R.string.content_type_manga),
|
||||
SOURCES(R.string.remote_sources),
|
||||
RECENT_SOURCES(R.string.recent_sources),
|
||||
AUTHORS(R.string.authors),
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
||||
|
||||
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
|
||||
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
|
||||
is ConfigKey.PreferredImageServer -> prefs.getString(key.key, key.defaultValue)?.takeUnless(String::isEmpty)
|
||||
} as T
|
||||
}
|
||||
|
||||
@@ -47,6 +48,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
||||
is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
|
||||
is ConfigKey.UserAgent -> putString(key.key, (value as String?)?.sanitizeHeaderValue())
|
||||
is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean)
|
||||
is ConfigKey.PreferredImageServer -> putString(key.key, value as String? ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
@@ -26,10 +28,12 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
|
||||
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
abstract class BaseActivity<B : ViewBinding> :
|
||||
AppCompatActivity(),
|
||||
ScreenshotPolicyHelper.ContentContainer,
|
||||
WindowInsetsDelegate.WindowInsetsListener {
|
||||
|
||||
private var isAmoledTheme = false
|
||||
@@ -151,6 +155,8 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
}
|
||||
}
|
||||
|
||||
override fun isNsfwContent(): Flow<Boolean> = flowOf(false)
|
||||
|
||||
private fun putDataToExtras(intent: Intent?) {
|
||||
intent?.putExtra(EXTRA_DATA, intent.data)
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ abstract class BaseViewModel : ViewModel() {
|
||||
errorEvent.call(error)
|
||||
}
|
||||
|
||||
protected inline suspend fun <T> withLoading(block: () -> T): T = try {
|
||||
protected inline fun <T> withLoading(block: () -> T): T = try {
|
||||
loadingCounter.increment()
|
||||
block()
|
||||
} finally {
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
package org.koitharu.kotatsu.core.ui.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.graphics.alpha
|
||||
import androidx.core.graphics.blue
|
||||
import androidx.core.graphics.get
|
||||
import androidx.core.graphics.green
|
||||
import androidx.core.graphics.red
|
||||
import coil.size.Size
|
||||
import coil.transform.Transformation
|
||||
import kotlin.math.abs
|
||||
import org.koitharu.kotatsu.reader.domain.EdgeDetector.Companion.isColorTheSame
|
||||
|
||||
class TrimTransformation(
|
||||
private val tolerance: Int = 20,
|
||||
@@ -28,7 +23,7 @@ class TrimTransformation(
|
||||
var isColBlank = true
|
||||
val prevColor = input[x, 0]
|
||||
for (y in 1 until input.height) {
|
||||
if (!isColorTheSame(input[x, y], prevColor)) {
|
||||
if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
|
||||
isColBlank = false
|
||||
break
|
||||
}
|
||||
@@ -47,7 +42,7 @@ class TrimTransformation(
|
||||
var isColBlank = true
|
||||
val prevColor = input[x, 0]
|
||||
for (y in 1 until input.height) {
|
||||
if (!isColorTheSame(input[x, y], prevColor)) {
|
||||
if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
|
||||
isColBlank = false
|
||||
break
|
||||
}
|
||||
@@ -63,7 +58,7 @@ class TrimTransformation(
|
||||
var isRowBlank = true
|
||||
val prevColor = input[0, y]
|
||||
for (x in 1 until input.width) {
|
||||
if (!isColorTheSame(input[x, y], prevColor)) {
|
||||
if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
|
||||
isRowBlank = false
|
||||
break
|
||||
}
|
||||
@@ -79,7 +74,7 @@ class TrimTransformation(
|
||||
var isRowBlank = true
|
||||
val prevColor = input[0, y]
|
||||
for (x in 1 until input.width) {
|
||||
if (!isColorTheSame(input[x, y], prevColor)) {
|
||||
if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
|
||||
isRowBlank = false
|
||||
break
|
||||
}
|
||||
@@ -98,13 +93,6 @@ class TrimTransformation(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isColorTheSame(@ColorInt a: Int, @ColorInt b: Int): Boolean {
|
||||
return abs(a.red - b.red) <= tolerance &&
|
||||
abs(a.green - b.green) <= tolerance &&
|
||||
abs(a.blue - b.blue) <= tolerance &&
|
||||
abs(a.alpha - b.alpha) <= tolerance
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return this === other || (other is TrimTransformation && other.tolerance == tolerance)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,16 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
||||
fastScroller.visibility = if (isFastScrollerEnabled) visibility else GONE
|
||||
}
|
||||
|
||||
override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.setPadding(left, top, right, bottom)
|
||||
fastScroller.setPadding(left, top, right, bottom)
|
||||
}
|
||||
|
||||
override fun setPaddingRelative(start: Int, top: Int, end: Int, bottom: Int) {
|
||||
super.setPaddingRelative(start, top, end, bottom)
|
||||
fastScroller.setPaddingRelative(start, top, end, bottom)
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
fastScroller.attachRecyclerView(this)
|
||||
|
||||
@@ -12,6 +12,8 @@ import com.google.android.material.chip.ChipGroup
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.castOrNull
|
||||
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class ChipsView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@@ -24,7 +26,9 @@ class ChipsView @JvmOverloads constructor(
|
||||
onChipClickListener?.onChipClick(it as Chip, it.tag)
|
||||
}
|
||||
private val chipOnCloseListener = OnClickListener {
|
||||
onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag)
|
||||
val chip = it as Chip
|
||||
val data = it.tag
|
||||
onChipCloseClickListener?.onChipCloseClick(chip, data) ?: onChipClickListener?.onChipClick(chip, data)
|
||||
}
|
||||
private val chipStyle: Int
|
||||
var onChipClickListener: OnChipClickListener? = null
|
||||
@@ -48,7 +52,7 @@ class ChipsView @JvmOverloads constructor(
|
||||
if (isInEditMode) {
|
||||
setChips(
|
||||
List(5) {
|
||||
ChipModel(0, "Chip $it", 0, isCheckable = false, isChecked = false)
|
||||
ChipModel(title = "Chip $it")
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -99,6 +103,15 @@ class ChipsView @JvmOverloads constructor(
|
||||
chip.isChipIconVisible = true
|
||||
}
|
||||
chip.isChecked = model.isChecked
|
||||
chip.isCheckedIconVisible = chip.isCheckable && model.icon == 0
|
||||
chip.isCloseIconVisible = if (onChipCloseClickListener != null || model.isDropdown) {
|
||||
chip.setCloseIconResource(
|
||||
if (model.isDropdown) R.drawable.ic_expand_more else materialR.drawable.ic_m3_chip_close,
|
||||
)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
chip.tag = model.data
|
||||
}
|
||||
|
||||
@@ -106,12 +119,11 @@ class ChipsView @JvmOverloads constructor(
|
||||
val chip = Chip(context)
|
||||
val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
|
||||
chip.setChipDrawable(drawable)
|
||||
chip.isCheckedIconVisible = true
|
||||
chip.isChipIconVisible = false
|
||||
chip.isCloseIconVisible = onChipCloseClickListener != null
|
||||
chip.setOnCloseIconClickListener(chipOnCloseListener)
|
||||
chip.setEnsureMinTouchTargetSize(false)
|
||||
chip.setOnClickListener(chipOnClickListener)
|
||||
chip.isElegantTextHeight = false
|
||||
addView(chip)
|
||||
return chip
|
||||
}
|
||||
@@ -127,11 +139,12 @@ class ChipsView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
data class ChipModel(
|
||||
@ColorRes val tint: Int,
|
||||
val title: CharSequence,
|
||||
@DrawableRes val icon: Int,
|
||||
val isCheckable: Boolean,
|
||||
val isChecked: Boolean,
|
||||
@DrawableRes val icon: Int = 0,
|
||||
val isCheckable: Boolean = false,
|
||||
@ColorRes val tint: Int = 0,
|
||||
val isChecked: Boolean = false,
|
||||
val isDropdown: Boolean = false,
|
||||
val data: Any? = null,
|
||||
)
|
||||
|
||||
|
||||
@@ -23,11 +23,16 @@ class SelectableTextView @JvmOverloads constructor(
|
||||
private fun fixSelectionRange() {
|
||||
if (selectionStart < 0 || selectionEnd < 0) {
|
||||
val spannableText = text as? Spannable ?: return
|
||||
Selection.setSelection(spannableText, text.length)
|
||||
Selection.setSelection(spannableText, spannableText.length)
|
||||
}
|
||||
}
|
||||
|
||||
override fun scrollTo(x: Int, y: Int) {
|
||||
super.scrollTo(0, 0)
|
||||
}
|
||||
|
||||
fun selectAll() {
|
||||
val spannableText = text as? Spannable ?: return
|
||||
Selection.selectAll(spannableText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.map
|
||||
import org.koitharu.kotatsu.core.util.ext.iterator
|
||||
import java.util.Locale
|
||||
|
||||
class LocaleComparator : Comparator<Locale> {
|
||||
|
||||
private val deviceLocales = LocaleListCompat.getAdjustedDefault()//LocaleManagerCompat.getSystemLocales(context)
|
||||
.map { it.language }
|
||||
.distinct()
|
||||
private val deviceLocales: List<String>
|
||||
|
||||
init {
|
||||
val localeList = LocaleListCompat.getAdjustedDefault()
|
||||
deviceLocales = buildList(localeList.size() + 1) {
|
||||
add("")
|
||||
val set = HashSet<String>(localeList.size() + 1)
|
||||
set.add("")
|
||||
for (locale in localeList) {
|
||||
val lang = locale.language
|
||||
if (set.add(lang)) {
|
||||
add(lang)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun compare(a: Locale, b: Locale): Int {
|
||||
val indexA = deviceLocales.indexOf(a.language)
|
||||
|
||||
@@ -69,4 +69,11 @@ fun <T> Iterable<T>.sortedWithSafe(comparator: Comparator<in T>): List<T> = try
|
||||
}
|
||||
}
|
||||
|
||||
fun Collection<*>?.sizeOrZero() = if (this == null) 0 else size
|
||||
fun Collection<*>?.sizeOrZero() = this?.size ?: 0
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
inline fun <T, reified R> Collection<T>.mapToArray(transform: (T) -> R): Array<R> {
|
||||
val result = arrayOfNulls<R>(size)
|
||||
forEachIndexed { index, t -> result[index] = transform(t) }
|
||||
return result as Array<R>
|
||||
}
|
||||
|
||||
@@ -12,12 +12,16 @@ import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.koitharu.kotatsu.core.util.AcraCoroutineErrorHandler
|
||||
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
|
||||
import org.koitharu.kotatsu.parsers.util.cancelAll
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
@@ -90,3 +94,10 @@ fun <T> Deferred<T>.peek(): T? = if (isCompleted) {
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@Suppress("SuspendFunctionOnCoroutineScope")
|
||||
suspend fun CoroutineScope.cancelChildrenAndJoin(cause: CancellationException? = null) {
|
||||
val jobs = coroutineContext[Job]?.children?.toList() ?: return
|
||||
jobs.cancelAll(cause)
|
||||
jobs.joinAll()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -11,3 +12,9 @@ fun Rect.scale(factor: Double) {
|
||||
(height() - newHeight) / 2,
|
||||
)
|
||||
}
|
||||
|
||||
inline fun <R> Bitmap.use(block: (Bitmap) -> R) = try {
|
||||
block(this)
|
||||
} finally {
|
||||
recycle()
|
||||
}
|
||||
|
||||
@@ -22,11 +22,10 @@ fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementE
|
||||
|
||||
fun String.toLocale() = Locale(this)
|
||||
|
||||
fun Locale?.getDisplayName(context: Context): String {
|
||||
if (this == null) {
|
||||
return context.getString(R.string.various_languages)
|
||||
}
|
||||
return getDisplayLanguage(this).toTitleCase(this)
|
||||
fun Locale?.getDisplayName(context: Context): String = when (this) {
|
||||
null -> context.getString(R.string.all_languages)
|
||||
Locale.ROOT -> context.getString(R.string.various_languages)
|
||||
else -> getDisplayLanguage(this).toTitleCase(this)
|
||||
}
|
||||
|
||||
private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator<Locale> {
|
||||
|
||||
@@ -34,10 +34,12 @@ import coil.util.CoilUtils
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNot
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
@@ -121,7 +123,6 @@ class DetailsActivity :
|
||||
lateinit var tagHighlighter: ListExtraProvider
|
||||
|
||||
private val viewModel: DetailsViewModel by viewModels()
|
||||
|
||||
private lateinit var menuProvider: DetailsMenuProvider
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -155,6 +156,7 @@ class DetailsActivity :
|
||||
viewBinding.containerBottomSheet?.let { sheet ->
|
||||
onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(sheet))
|
||||
}
|
||||
TitleExpandListener(viewBinding.textViewTitle).attach()
|
||||
|
||||
viewModel.details.filterNotNull().observe(this, ::onMangaUpdated)
|
||||
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
|
||||
@@ -197,6 +199,8 @@ class DetailsActivity :
|
||||
addMenuProvider(menuProvider)
|
||||
}
|
||||
|
||||
override fun isNsfwContent(): Flow<Boolean> = viewModel.manga.map { it?.isNsfw == true }
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_read -> openReader(isIncognitoMode = false)
|
||||
@@ -459,7 +463,7 @@ class DetailsActivity :
|
||||
imageViewState.isVisible = false
|
||||
}
|
||||
|
||||
if (manga.source == MangaSource.LOCAL || manga.source == MangaSource.DUMMY) {
|
||||
if (manga.source == MangaSource.LOCAL || manga.source == MangaSource.UNKNOWN) {
|
||||
infoLayout.chipSource.isVisible = false
|
||||
} else {
|
||||
infoLayout.chipSource.text = manga.source.title
|
||||
@@ -534,7 +538,7 @@ class DetailsActivity :
|
||||
}
|
||||
val isFirstCall = buttonRead.tag == null
|
||||
buttonRead.tag = Unit
|
||||
buttonRead.setProgress(info.history?.percent?.coerceIn(0f, 1f) ?: 0f, !isFirstCall)
|
||||
buttonRead.setProgress(info.percent.coerceIn(0f, 1f), !isFirstCall)
|
||||
buttonDownload?.isEnabled = info.isValid && info.canDownload
|
||||
buttonRead.isEnabled = info.isValid
|
||||
}
|
||||
@@ -612,10 +616,7 @@ class DetailsActivity :
|
||||
ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
tint = tagHighlighter.getTagTint(tag),
|
||||
icon = 0,
|
||||
data = tag,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -93,15 +93,19 @@ class DetailsViewModel @Inject constructor(
|
||||
|
||||
val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) })
|
||||
val manga = details.map { x -> x?.toManga() }
|
||||
.withErrorHandling()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
|
||||
val history = historyRepository.observeOne(mangaId)
|
||||
.withErrorHandling()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
|
||||
val favouriteCategories = interactor.observeFavourite(mangaId)
|
||||
.withErrorHandling()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
val isStatsAvailable = statsRepository.observeHasStats(mangaId)
|
||||
.withErrorHandling()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
|
||||
|
||||
val remoteManga = MutableStateFlow<Manga?>(null)
|
||||
@@ -162,7 +166,7 @@ class DetailsViewModel @Inject constructor(
|
||||
|
||||
val onMangaRemoved = MutableEventFlow<Manga>()
|
||||
val isScrobblingAvailable: Boolean
|
||||
get() = scrobblers.any { it.isAvailable }
|
||||
get() = scrobblers.any { it.isEnabled }
|
||||
|
||||
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
@@ -393,7 +397,7 @@ class DetailsViewModel @Inject constructor(
|
||||
private fun getScrobbler(index: Int): Scrobbler? {
|
||||
val info = scrobblingInfo.value.getOrNull(index)
|
||||
val scrobbler = if (info != null) {
|
||||
scrobblers.find { it.scrobblerService == info.scrobbler && it.isAvailable }
|
||||
scrobblers.find { it.scrobblerService == info.scrobbler && it.isEnabled }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.transition.TransitionManager
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.View.OnTouchListener
|
||||
import android.view.ViewGroup
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.widgets.SelectableTextView
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
class TitleExpandListener(
|
||||
private val textView: SelectableTextView,
|
||||
) : GestureDetector.SimpleOnGestureListener(), OnTouchListener {
|
||||
|
||||
private val gestureDetector = GestureDetector(textView.context, this)
|
||||
private val linesExpanded = textView.resources.getInteger(R.integer.details_description_lines)
|
||||
private val linesCollapsed = textView.resources.getInteger(R.integer.details_title_lines)
|
||||
|
||||
override fun onTouch(v: View?, event: MotionEvent) = gestureDetector.onTouchEvent(event)
|
||||
|
||||
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
|
||||
if (textView.context.isAnimationsEnabled) {
|
||||
TransitionManager.beginDelayedTransition(textView.parent as ViewGroup)
|
||||
}
|
||||
if (textView.maxLines in 1 until Integer.MAX_VALUE) {
|
||||
textView.maxLines = Integer.MAX_VALUE
|
||||
} else {
|
||||
textView.maxLines = linesCollapsed
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onLongPress(e: MotionEvent) {
|
||||
textView.maxLines = Integer.MAX_VALUE
|
||||
textView.selectAll()
|
||||
}
|
||||
|
||||
fun attach() {
|
||||
textView.setOnTouchListener(this)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,13 @@ data class HistoryInfo(
|
||||
|
||||
val canContinue
|
||||
get() = currentChapter >= 0
|
||||
|
||||
val percent: Float
|
||||
get() = if (history != null && (canContinue || isChapterMissing)) {
|
||||
history.percent
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
|
||||
fun HistoryInfo(
|
||||
@@ -24,7 +31,11 @@ fun HistoryInfo(
|
||||
history: MangaHistory?,
|
||||
isIncognitoMode: Boolean
|
||||
): HistoryInfo {
|
||||
val chapters = manga?.chapters?.get(branch)
|
||||
val chapters = if (manga?.chapters?.isEmpty() == true) {
|
||||
emptyList()
|
||||
} else {
|
||||
manga?.chapters?.get(branch)
|
||||
}
|
||||
val currentChapter = if (history != null && !chapters.isNullOrEmpty()) {
|
||||
chapters.indexOfFirst { it.id == history.chapterId }
|
||||
} else {
|
||||
|
||||
@@ -92,7 +92,7 @@ class MangaPageFetcher(
|
||||
}
|
||||
|
||||
else -> {
|
||||
val request = PageLoader.createPageRequest(page, pageUrl)
|
||||
val request = PageLoader.createPageRequest(pageUrl, page.source)
|
||||
imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw HttpException(response)
|
||||
|
||||
@@ -35,16 +35,16 @@ import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.IOException
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.use
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
|
||||
import org.koitharu.kotatsu.core.model.ids
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
@@ -55,6 +55,7 @@ import org.koitharu.kotatsu.core.util.ext.awaitWorkInfosByTag
|
||||
import org.koitharu.kotatsu.core.util.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.core.util.ext.deleteWork
|
||||
import org.koitharu.kotatsu.core.util.ext.deleteWorks
|
||||
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.getWorkInputData
|
||||
import org.koitharu.kotatsu.core.util.ext.getWorkSpec
|
||||
@@ -73,9 +74,9 @@ import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -93,6 +94,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val settings: AppSettings,
|
||||
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
|
||||
private val imageProxyInterceptor: ImageProxyInterceptor,
|
||||
notificationFactoryFactory: DownloadNotificationFactory.Factory,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
@@ -327,28 +329,24 @@ class DownloadWorker @AssistedInject constructor(
|
||||
destination: File,
|
||||
source: MangaSource,
|
||||
): File {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.tag(MangaSource::class.java, source)
|
||||
.header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8")
|
||||
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
|
||||
.get()
|
||||
.build()
|
||||
val request = PageLoader.createPageRequest(url, source)
|
||||
slowdownDispatcher.delay(source)
|
||||
val call = okHttp.newCall(request)
|
||||
val file = File(destination, UUID.randomUUID().toString() + ".tmp")
|
||||
try {
|
||||
val response = call.clone().await()
|
||||
checkNotNull(response.body).use { body ->
|
||||
file.sink(append = false).buffer().use {
|
||||
it.writeAllCancellable(body.source())
|
||||
return imageProxyInterceptor.interceptPageRequest(request, okHttp)
|
||||
.ensureSuccess()
|
||||
.use { response ->
|
||||
val file = File(destination, UUID.randomUUID().toString() + ".tmp")
|
||||
try {
|
||||
checkNotNull(response.body).use { body ->
|
||||
file.sink(append = false).buffer().use {
|
||||
it.writeAllCancellable(body.source())
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
file.delete()
|
||||
throw e
|
||||
}
|
||||
file
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
file.delete()
|
||||
throw e
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
private suspend fun publishState(state: DownloadState) {
|
||||
|
||||
@@ -6,21 +6,22 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import java.util.Collections
|
||||
import java.util.EnumSet
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
@@ -29,11 +30,13 @@ class MangaSourcesRepository @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
private val isNewSourcesAssimilated = AtomicBoolean(false)
|
||||
private val dao: MangaSourcesDao
|
||||
get() = db.getSourcesDao()
|
||||
|
||||
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
|
||||
remove(MangaSource.LOCAL)
|
||||
remove(MangaSource.UNKNOWN)
|
||||
if (!BuildConfig.DEBUG) {
|
||||
remove(MangaSource.DUMMY)
|
||||
}
|
||||
@@ -43,25 +46,75 @@ class MangaSourcesRepository @Inject constructor(
|
||||
get() = Collections.unmodifiableSet(remoteSources)
|
||||
|
||||
suspend fun getEnabledSources(): List<MangaSource> {
|
||||
assimilateNewSources()
|
||||
val order = settings.sourcesSortOrder
|
||||
return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order)
|
||||
}
|
||||
|
||||
suspend fun getPinnedSources(): Set<MangaSource> {
|
||||
assimilateNewSources()
|
||||
val skipNsfw = settings.isNsfwContentDisabled
|
||||
return dao.findAllPinned().mapNotNullTo(EnumSet.noneOf(MangaSource::class.java)) {
|
||||
it.source.toMangaSourceOrNull()?.takeUnless { x -> skipNsfw && x.isNsfw() }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getTopSources(limit: Int): List<MangaSource> {
|
||||
assimilateNewSources()
|
||||
return dao.findLastUsed(limit).toSources(settings.isNsfwContentDisabled, null)
|
||||
}
|
||||
|
||||
suspend fun getDisabledSources(): Set<MangaSource> {
|
||||
assimilateNewSources()
|
||||
val result = EnumSet.copyOf(remoteSources)
|
||||
val enabled = dao.findAllEnabledNames()
|
||||
for (name in enabled) {
|
||||
val source = MangaSource(name)
|
||||
val source = name.toMangaSourceOrNull() ?: continue
|
||||
result.remove(source)
|
||||
}
|
||||
if (settings.isNsfwContentDisabled) {
|
||||
result.removeAll { it.isNsfw() }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun getAvailableSources(
|
||||
isDisabledOnly: Boolean,
|
||||
isNewOnly: Boolean,
|
||||
excludeBroken: Boolean,
|
||||
types: Set<ContentType>,
|
||||
query: String?,
|
||||
locale: String?,
|
||||
sortOrder: SourcesSortOrder?,
|
||||
): List<MangaSource> {
|
||||
assimilateNewSources()
|
||||
val entities = dao.findAll().toMutableList()
|
||||
if (isDisabledOnly) {
|
||||
entities.removeAll { it.isEnabled }
|
||||
}
|
||||
if (isNewOnly) {
|
||||
entities.retainAll { it.addedIn == BuildConfig.VERSION_CODE }
|
||||
}
|
||||
val sources = entities.toSources(
|
||||
skipNsfwSources = settings.isNsfwContentDisabled,
|
||||
sortOrder = sortOrder,
|
||||
)
|
||||
if (locale != null) {
|
||||
sources.retainAll { it.locale == locale }
|
||||
}
|
||||
if (excludeBroken) {
|
||||
sources.removeAll { it.isBroken }
|
||||
}
|
||||
if (types.isNotEmpty()) {
|
||||
sources.retainAll { it.contentType in types }
|
||||
}
|
||||
if (!query.isNullOrEmpty()) {
|
||||
sources.retainAll {
|
||||
it.title.contains(query, ignoreCase = true) || it.name.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
fun observeIsEnabled(source: MangaSource): Flow<Boolean> {
|
||||
return dao.observeIsEnabled(source.name)
|
||||
return dao.observeIsEnabled(source.name).onStart { assimilateNewSources() }
|
||||
}
|
||||
|
||||
fun observeEnabledSourcesCount(): Flow<Int> {
|
||||
@@ -69,8 +122,10 @@ class MangaSourcesRepository @Inject constructor(
|
||||
observeIsNsfwDisabled(),
|
||||
dao.observeEnabled(SourcesSortOrder.MANUAL),
|
||||
) { skipNsfw, sources ->
|
||||
sources.count { !skipNsfw || !MangaSource(it.source).isNsfw() }
|
||||
}.distinctUntilChanged()
|
||||
sources.count {
|
||||
it.source.toMangaSourceOrNull()?.let { s -> !skipNsfw || !s.isNsfw() } == true
|
||||
}
|
||||
}.distinctUntilChanged().onStart { assimilateNewSources() }
|
||||
}
|
||||
|
||||
fun observeAvailableSourcesCount(): Flow<Int> {
|
||||
@@ -82,7 +137,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
allMangaSources.count { x ->
|
||||
x.name !in enabled && (!skipNsfw || !x.isNsfw())
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
}.distinctUntilChanged().onStart { assimilateNewSources() }
|
||||
}
|
||||
|
||||
fun observeEnabledSources(): Flow<List<MangaSource>> = combine(
|
||||
@@ -92,18 +147,18 @@ class MangaSourcesRepository @Inject constructor(
|
||||
dao.observeEnabled(order).map {
|
||||
it.toSources(skipNsfw, order)
|
||||
}
|
||||
}.flatMapLatest { it }
|
||||
}.flatMapLatest { it }.onStart { assimilateNewSources() }
|
||||
|
||||
fun observeAll(): Flow<List<Pair<MangaSource, Boolean>>> = dao.observeAll().map { entities ->
|
||||
val result = ArrayList<Pair<MangaSource, Boolean>>(entities.size)
|
||||
for (entity in entities) {
|
||||
val source = MangaSource(entity.source)
|
||||
val source = entity.source.toMangaSourceOrNull() ?: continue
|
||||
if (source in remoteSources) {
|
||||
result.add(source to entity.isEnabled)
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}.onStart { assimilateNewSources() }
|
||||
|
||||
suspend fun setSourcesEnabled(sources: Collection<MangaSource>, isEnabled: Boolean): ReversibleHandle {
|
||||
setSourcesEnabledImpl(sources, isEnabled)
|
||||
@@ -114,6 +169,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
|
||||
suspend fun setSourcesEnabledExclusive(sources: Set<MangaSource>) {
|
||||
db.withTransaction {
|
||||
assimilateNewSources()
|
||||
for (s in remoteSources) {
|
||||
dao.setEnabled(s.name, s in sources)
|
||||
}
|
||||
@@ -135,31 +191,34 @@ class MangaSourcesRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun observeNewSources(): Flow<Set<MangaSource>> = observeIsNewSourcesEnabled().flatMapLatest {
|
||||
if (it) {
|
||||
combine(
|
||||
dao.observeAll(),
|
||||
observeIsNsfwDisabled(),
|
||||
) { entities, skipNsfw ->
|
||||
val result = EnumSet.copyOf(remoteSources)
|
||||
for (e in entities) {
|
||||
result.remove(MangaSource(e.source))
|
||||
}
|
||||
if (skipNsfw) {
|
||||
result.removeAll { x -> x.isNsfw() }
|
||||
}
|
||||
result
|
||||
}.distinctUntilChanged()
|
||||
fun observeHasNewSources(): Flow<Boolean> = observeIsNsfwDisabled().map { skipNsfw ->
|
||||
val sources = dao.findAllFromVersion(BuildConfig.VERSION_CODE).toSources(skipNsfw, null)
|
||||
sources.isNotEmpty() && sources.size != remoteSources.size
|
||||
}.onStart { assimilateNewSources() }
|
||||
|
||||
fun observeHasNewSourcesForBadge(): Flow<Boolean> = combine(
|
||||
settings.observeAsFlow(AppSettings.KEY_SOURCES_VERSION) { sourcesVersion },
|
||||
observeIsNsfwDisabled(),
|
||||
) { version, skipNsfw ->
|
||||
if (version < BuildConfig.VERSION_CODE) {
|
||||
val sources = dao.findAllFromVersion(version).toSources(skipNsfw, null)
|
||||
sources.isNotEmpty()
|
||||
} else {
|
||||
assimilateNewSources()
|
||||
flowOf(emptySet())
|
||||
false
|
||||
}
|
||||
}.onStart { assimilateNewSources() }
|
||||
|
||||
fun clearNewSourcesBadge() {
|
||||
settings.sourcesVersion = BuildConfig.VERSION_CODE
|
||||
}
|
||||
|
||||
suspend fun assimilateNewSources(): Set<MangaSource> {
|
||||
private suspend fun assimilateNewSources(): Boolean {
|
||||
if (isNewSourcesAssimilated.getAndSet(true)) {
|
||||
return false
|
||||
}
|
||||
val new = getNewSources()
|
||||
if (new.isEmpty()) {
|
||||
return emptySet()
|
||||
return false
|
||||
}
|
||||
var maxSortKey = dao.getMaxSortKey()
|
||||
val entities = new.map { x ->
|
||||
@@ -167,17 +226,30 @@ class MangaSourcesRepository @Inject constructor(
|
||||
source = x.name,
|
||||
isEnabled = false,
|
||||
sortKey = ++maxSortKey,
|
||||
addedIn = BuildConfig.VERSION_CODE,
|
||||
lastUsedAt = 0,
|
||||
isPinned = false,
|
||||
)
|
||||
}
|
||||
dao.insertIfAbsent(entities)
|
||||
if (settings.isNsfwContentDisabled) {
|
||||
new.removeAll { x -> x.isNsfw() }
|
||||
}
|
||||
return new
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun isSetupRequired(): Boolean {
|
||||
return dao.findAll().isEmpty()
|
||||
return settings.sourcesVersion == 0 && dao.findAllEnabledNames().isEmpty()
|
||||
}
|
||||
|
||||
suspend fun setIsPinned(sources: Collection<MangaSource>, isPinned: Boolean): ReversibleHandle {
|
||||
setSourcesPinnedImpl(sources, isPinned)
|
||||
return ReversibleHandle {
|
||||
setSourcesEnabledImpl(sources, !isPinned)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun trackUsage(source: MangaSource) {
|
||||
if (!settings.isIncognitoModeEnabled && !(settings.isHistoryExcludeNsfw && source.isNsfw())) {
|
||||
dao.setLastUsed(source.name, System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setSourcesEnabledImpl(sources: Collection<MangaSource>, isEnabled: Boolean) {
|
||||
@@ -192,11 +264,23 @@ class MangaSourcesRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setSourcesPinnedImpl(sources: Collection<MangaSource>, isPinned: Boolean) {
|
||||
if (sources.size == 1) { // fast path
|
||||
dao.setPinned(sources.first().name, isPinned)
|
||||
return
|
||||
}
|
||||
db.withTransaction {
|
||||
for (source in sources) {
|
||||
dao.setPinned(source.name, isPinned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getNewSources(): MutableSet<MangaSource> {
|
||||
val entities = dao.findAll()
|
||||
val result = EnumSet.copyOf(remoteSources)
|
||||
for (e in entities) {
|
||||
result.remove(MangaSource(e.source))
|
||||
result.remove(e.source.toMangaSourceOrNull() ?: continue)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -204,19 +288,23 @@ class MangaSourcesRepository @Inject constructor(
|
||||
private fun List<MangaSourceEntity>.toSources(
|
||||
skipNsfwSources: Boolean,
|
||||
sortOrder: SourcesSortOrder?,
|
||||
): List<MangaSource> {
|
||||
): MutableList<MangaSource> {
|
||||
val result = ArrayList<MangaSource>(size)
|
||||
val pinned = EnumSet.noneOf(MangaSource::class.java)
|
||||
for (entity in this) {
|
||||
val source = MangaSource(entity.source)
|
||||
val source = entity.source.toMangaSourceOrNull() ?: continue
|
||||
if (skipNsfwSources && source.isNsfw()) {
|
||||
continue
|
||||
}
|
||||
if (source in remoteSources) {
|
||||
result.add(source)
|
||||
if (entity.isPinned) {
|
||||
pinned.add(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sortOrder == SourcesSortOrder.ALPHABETIC) {
|
||||
result.sortBy { it.title }
|
||||
result.sortWith(compareBy<MangaSource> { it in pinned }.thenBy { it.title })
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -225,11 +313,9 @@ class MangaSourcesRepository @Inject constructor(
|
||||
isNsfwContentDisabled
|
||||
}
|
||||
|
||||
private fun observeIsNewSourcesEnabled() = settings.observeAsFlow(AppSettings.KEY_SOURCES_NEW) {
|
||||
isNewSourcesTipEnabled
|
||||
}
|
||||
|
||||
private fun observeSortOrder() = settings.observeAsFlow(AppSettings.KEY_SOURCES_ORDER) {
|
||||
sourcesSortOrder
|
||||
}
|
||||
|
||||
private fun String.toMangaSourceOrNull(): MangaSource? = MangaSource.entries.find { it.name == this }
|
||||
}
|
||||
|
||||
@@ -9,4 +9,5 @@ enum class SourcesSortOrder(
|
||||
ALPHABETIC(R.string.by_name),
|
||||
POPULARITY(R.string.popular),
|
||||
MANUAL(R.string.manual),
|
||||
LAST_USED(R.string.last_used),
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.ui.util.SpanSizeResolver
|
||||
import org.koitharu.kotatsu.core.ui.widgets.TipView
|
||||
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
@@ -40,13 +39,11 @@ import org.koitharu.kotatsu.explore.ui.adapter.ExploreListEventListener
|
||||
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.TipModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
|
||||
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
|
||||
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
|
||||
import javax.inject.Inject
|
||||
@@ -56,7 +53,7 @@ class ExploreFragment :
|
||||
BaseFragment<FragmentExploreBinding>(),
|
||||
RecyclerViewOwner,
|
||||
ExploreListEventListener,
|
||||
OnListItemClickListener<MangaSourceItem>, TipView.OnButtonClickListener, ListSelectionController.Callback2 {
|
||||
OnListItemClickListener<MangaSourceItem>, ListSelectionController.Callback2 {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
@@ -74,7 +71,7 @@ class ExploreFragment :
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentExploreBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
exploreAdapter = ExploreAdapter(coil, viewLifecycleOwner, this, this, this) { manga, view ->
|
||||
exploreAdapter = ExploreAdapter(coil, viewLifecycleOwner, this, this) { manga, view ->
|
||||
startActivity(DetailsActivity.newIntent(view.context, manga))
|
||||
}
|
||||
sourceSelectionController = ListSelectionController(
|
||||
@@ -124,18 +121,6 @@ class ExploreFragment :
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPrimaryButtonClick(tipView: TipView) {
|
||||
when ((tipView.tag as? TipModel)?.key) {
|
||||
ExploreViewModel.TIP_NEW_SOURCES -> NewSourcesDialogFragment.show(childFragmentManager)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSecondaryButtonClick(tipView: TipView) {
|
||||
when ((tipView.tag as? TipModel)?.key) {
|
||||
ExploreViewModel.TIP_NEW_SOURCES -> viewModel.discardNewSources()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
val intent = when (v.id) {
|
||||
R.id.button_local -> MangaListActivity.newIntent(v.context, MangaSource.LOCAL)
|
||||
@@ -211,6 +196,16 @@ class ExploreFragment :
|
||||
mode.finish()
|
||||
}
|
||||
|
||||
R.id.action_pin -> {
|
||||
viewModel.setSourcesPinned(selectedSources, isPinned = true)
|
||||
mode.finish()
|
||||
}
|
||||
|
||||
R.id.action_unpin -> {
|
||||
viewModel.setSourcesPinned(selectedSources, isPinned = false)
|
||||
mode.finish()
|
||||
}
|
||||
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -102,18 +102,24 @@ class ExploreViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun discardNewSources() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
sourcesRepository.assimilateNewSources()
|
||||
}
|
||||
}
|
||||
|
||||
fun requestPinShortcut(source: MangaSource) {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
shortcutManager.requestPinShortcut(source)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSourcesPinned(sources: Set<MangaSource>, isPinned: Boolean) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
sourcesRepository.setIsPinned(sources, isPinned)
|
||||
val message = if (sources.size == 1) {
|
||||
if (isPinned) R.string.source_pinned else R.string.source_unpinned
|
||||
} else {
|
||||
if (isPinned) R.string.sources_pinned else R.string.sources_unpinned
|
||||
}
|
||||
onActionDone.call(ReversibleAction(message, null))
|
||||
}
|
||||
}
|
||||
|
||||
fun respondSuggestionTip(isAccepted: Boolean) {
|
||||
settings.isSuggestionsEnabled = isAccepted
|
||||
settings.closeTip(TIP_SUGGESTIONS)
|
||||
@@ -124,7 +130,7 @@ class ExploreViewModel @Inject constructor(
|
||||
getSuggestionFlow(),
|
||||
isGrid,
|
||||
isRandomLoading,
|
||||
sourcesRepository.observeNewSources(),
|
||||
sourcesRepository.observeHasNewSourcesForBadge(),
|
||||
) { content, suggestions, grid, randomLoading, newSources ->
|
||||
buildList(content, suggestions, grid, randomLoading, newSources)
|
||||
}.withErrorHandling()
|
||||
@@ -134,7 +140,7 @@ class ExploreViewModel @Inject constructor(
|
||||
recommendation: List<Manga>,
|
||||
isGrid: Boolean,
|
||||
randomLoading: Boolean,
|
||||
newSources: Set<MangaSource>,
|
||||
hasNewSources: Boolean,
|
||||
): List<ListModel> {
|
||||
val result = ArrayList<ListModel>(sources.size + 3)
|
||||
result += ExploreButtons(randomLoading)
|
||||
@@ -146,7 +152,7 @@ class ExploreViewModel @Inject constructor(
|
||||
result += ListHeader(
|
||||
textRes = R.string.remote_sources,
|
||||
buttonTextRes = R.string.catalog,
|
||||
badge = if (newSources.isNotEmpty()) "" else null,
|
||||
badge = if (hasNewSources) "" else null,
|
||||
)
|
||||
sources.mapTo(result) { MangaSourceItem(it, isGrid) }
|
||||
} else {
|
||||
@@ -191,6 +197,5 @@ class ExploreViewModel @Inject constructor(
|
||||
|
||||
private const val TIP_SUGGESTIONS = "suggestions"
|
||||
private const val SUGGESTIONS_COUNT = 8
|
||||
const val TIP_NEW_SOURCES = "new_sources"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@ import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.widgets.TipView
|
||||
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.emptyHintAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.tipAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
@@ -18,7 +16,6 @@ class ExploreAdapter(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
listener: ExploreListEventListener,
|
||||
tipClickListener: TipView.OnButtonClickListener,
|
||||
clickListener: OnListItemClickListener<MangaSourceItem>,
|
||||
mangaClickListener: OnListItemClickListener<Manga>,
|
||||
) : BaseListAdapter<ListModel>() {
|
||||
@@ -34,6 +31,5 @@ class ExploreAdapter(
|
||||
addDelegate(ListItemType.EXPLORE_SOURCE_GRID, exploreSourceGridItemAD(coil, clickListener, lifecycleOwner))
|
||||
addDelegate(ListItemType.HINT_EMPTY, emptyHintAD(coil, lifecycleOwner, listener))
|
||||
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||
addDelegate(ListItemType.TIP, tipAD(tipClickListener))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ class FilterCoordinator @Inject constructor(
|
||||
}
|
||||
oldValue.copy(
|
||||
tagsExclude = newTags,
|
||||
tags = oldValue.tags - newTags
|
||||
tags = oldValue.tags - newTags,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -308,7 +308,7 @@ class FilterCoordinator @Inject constructor(
|
||||
currentState.update { oldValue ->
|
||||
oldValue.copy(
|
||||
tags = tags,
|
||||
tagsExclude = oldValue.tagsExclude - tags
|
||||
tagsExclude = oldValue.tagsExclude - tags,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -391,9 +391,7 @@ class FilterCoordinator @Inject constructor(
|
||||
val result = LinkedList<ChipsView.ChipModel>()
|
||||
for (tag in tags) {
|
||||
val model = ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = tag.title,
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = selectedTags.remove(tag),
|
||||
data = tag,
|
||||
@@ -406,9 +404,7 @@ class FilterCoordinator @Inject constructor(
|
||||
}
|
||||
for (tag in selectedTags) {
|
||||
val model = ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = tag.title,
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = true,
|
||||
data = tag,
|
||||
|
||||
@@ -61,10 +61,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
|
||||
}
|
||||
|
||||
private fun moreTagsChip() = ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = getString(R.string.more),
|
||||
icon = materialR.drawable.abc_ic_menu_overflow_material,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.koitharu.kotatsu.core.ui.model.titleRes
|
||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.parentView
|
||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||
@@ -29,7 +30,6 @@ import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
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.toTitleCase
|
||||
import java.util.Locale
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -122,10 +122,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
b.spinnerLocale.context,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
android.R.id.text1,
|
||||
value.availableItems.map {
|
||||
it?.getDisplayLanguage(it)?.toTitleCase(it)
|
||||
?: b.spinnerLocale.context.getString(R.string.various_languages)
|
||||
},
|
||||
value.availableItems.map { it.getDisplayName(b.spinnerLocale.context) },
|
||||
)
|
||||
val selectedIndex = value.availableItems.indexOf(selected)
|
||||
if (selectedIndex >= 0) {
|
||||
@@ -144,9 +141,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
val chips = ArrayList<ChipsView.ChipModel>(value.selectedItems.size + value.availableItems.size + 1)
|
||||
value.selectedItems.mapTo(chips) { tag ->
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = tag.title,
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = true,
|
||||
data = tag,
|
||||
@@ -155,9 +150,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
value.availableItems.mapNotNullTo(chips) { tag ->
|
||||
if (tag !in value.selectedItems) {
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = tag.title,
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = false,
|
||||
data = tag,
|
||||
@@ -168,12 +161,8 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
}
|
||||
chips.add(
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = getString(R.string.more),
|
||||
icon = materialR.drawable.abc_ic_menu_overflow_material,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
data = null,
|
||||
),
|
||||
)
|
||||
b.chipsGenres.setChips(chips)
|
||||
@@ -200,9 +189,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
value.availableItems.mapNotNullTo(chips) { tag ->
|
||||
if (tag !in value.selectedItems) {
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = tag.title,
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = false,
|
||||
data = tag,
|
||||
@@ -213,12 +200,8 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
}
|
||||
chips.add(
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = getString(R.string.more),
|
||||
icon = materialR.drawable.abc_ic_menu_overflow_material,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
data = null,
|
||||
),
|
||||
)
|
||||
b.chipsGenresExclude.setChips(chips)
|
||||
@@ -233,9 +216,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
}
|
||||
val chips = value.availableItems.map { state ->
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = getString(state.titleResId),
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = state in value.selectedItems,
|
||||
data = state,
|
||||
@@ -253,9 +234,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
}
|
||||
val chips = value.availableItems.map { contentRating ->
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = getString(contentRating.titleResId),
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = contentRating in value.selectedItems,
|
||||
data = contentRating,
|
||||
|
||||
@@ -37,9 +37,6 @@ suspend fun Manga.toListDetailedModel(
|
||||
ChipsView.ChipModel(
|
||||
tint = extraProvider?.getTagTint(it) ?: 0,
|
||||
title = it.title,
|
||||
icon = 0,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
data = it,
|
||||
)
|
||||
},
|
||||
|
||||
@@ -85,10 +85,7 @@ class PreviewViewModel @Inject constructor(
|
||||
ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
tint = extraProvider.getTagTint(tag),
|
||||
icon = 0,
|
||||
data = tag,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
)
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
@@ -227,9 +227,11 @@ class LocalMangaRepository @Inject constructor(
|
||||
}.filterNotNullTo(ArrayList(files.size))
|
||||
}
|
||||
|
||||
private suspend fun getAllFiles() = storageManager.getReadableDirs().asSequence().flatMap { dir ->
|
||||
dir.children()
|
||||
}
|
||||
private suspend fun getAllFiles() = storageManager.getReadableDirs()
|
||||
.asSequence()
|
||||
.flatMap { dir ->
|
||||
dir.children().filterNot { it.isHidden }
|
||||
}
|
||||
|
||||
private fun Collection<LocalManga>.unwrap(): List<Manga> = map { it.manga }
|
||||
}
|
||||
|
||||
@@ -82,6 +82,13 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
val cache = lruCache.get()
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
cache.clearCache()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getAvailableSize(): Long = runCatchingCancellable {
|
||||
val statFs = StatFs(cacheDir.get().absolutePath)
|
||||
statFs.availableBytes
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.explore.domain.ExploreRepository
|
||||
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
||||
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
||||
@@ -39,6 +40,7 @@ class LocalListViewModel @Inject constructor(
|
||||
exploreRepository: ExploreRepository,
|
||||
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
|
||||
private val localStorageManager: LocalStorageManager,
|
||||
sourcesRepository: MangaSourcesRepository,
|
||||
) : RemoteListViewModel(
|
||||
savedStateHandle,
|
||||
mangaRepositoryFactory,
|
||||
@@ -47,6 +49,7 @@ class LocalListViewModel @Inject constructor(
|
||||
listExtraProvider,
|
||||
downloadScheduler,
|
||||
exploreRepository,
|
||||
sourcesRepository,
|
||||
), SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
val onMangaRemoved = MutableEventFlow<Unit>()
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.koitharu.kotatsu.main.ui.protect
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks
|
||||
import javax.inject.Inject
|
||||
|
||||
class ScreenshotPolicyHelper @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) : DefaultActivityLifecycleCallbacks {
|
||||
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||
(activity as? ContentContainer)?.setupScreenshotPolicy(activity)
|
||||
}
|
||||
|
||||
private fun ContentContainer.setupScreenshotPolicy(activity: Activity) =
|
||||
lifecycleScope.launch(Dispatchers.Default) {
|
||||
settings.observeAsFlow(AppSettings.KEY_SCREENSHOTS_POLICY) { screenshotsPolicy }
|
||||
.flatMapLatest { policy ->
|
||||
when (policy) {
|
||||
ScreenshotsPolicy.ALLOW -> flowOf(false)
|
||||
ScreenshotsPolicy.BLOCK_NSFW -> withContext(Dispatchers.Main) {
|
||||
isNsfwContent()
|
||||
}.distinctUntilChanged()
|
||||
|
||||
ScreenshotsPolicy.BLOCK_ALL -> flowOf(true)
|
||||
ScreenshotsPolicy.BLOCK_INCOGNITO -> settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) {
|
||||
isIncognitoModeEnabled
|
||||
}
|
||||
}
|
||||
}.collect { isSecure ->
|
||||
withContext(Dispatchers.Main) {
|
||||
if (isSecure) {
|
||||
activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
} else {
|
||||
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ContentContainer : LifecycleOwner {
|
||||
|
||||
@MainThread
|
||||
fun isNsfwContent(): Flow<Boolean>
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,13 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.titleResId
|
||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
||||
import org.koitharu.kotatsu.databinding.SheetWelcomeBinding
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment
|
||||
import java.util.Locale
|
||||
|
||||
@@ -58,7 +58,7 @@ class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipC
|
||||
override fun onChipClick(chip: Chip, data: Any?) {
|
||||
when (data) {
|
||||
is ContentType -> viewModel.setTypeChecked(data, chip.isChecked)
|
||||
is Locale? -> viewModel.setLocaleChecked(data, chip.isChecked)
|
||||
is Locale -> viewModel.setLocaleChecked(data, chip.isChecked)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,14 +86,12 @@ class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipC
|
||||
}
|
||||
}
|
||||
|
||||
private fun onLocalesChanged(value: FilterProperty<Locale?>) {
|
||||
private fun onLocalesChanged(value: FilterProperty<Locale>) {
|
||||
val chips = viewBinding?.chipsLocales ?: return
|
||||
chips.setChips(
|
||||
value.availableItems.map {
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = it?.getDisplayLanguage(it)?.toTitleCase(it) ?: getString(R.string.various_languages),
|
||||
icon = 0,
|
||||
title = it.getDisplayName(chips.context),
|
||||
isCheckable = true,
|
||||
isChecked = it in value.selectedItems,
|
||||
data = it,
|
||||
@@ -107,9 +105,7 @@ class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipC
|
||||
chips.setChips(
|
||||
value.availableItems.map {
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = getString(it.titleResId),
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = it in value.selectedItems,
|
||||
data = it,
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.LocaleComparator
|
||||
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
|
||||
import org.koitharu.kotatsu.core.util.ext.toList
|
||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
@@ -27,14 +28,14 @@ class WelcomeViewModel @Inject constructor(
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val allSources = repository.allMangaSources
|
||||
private val localesGroups by lazy { allSources.groupBy { it.locale?.let { x -> Locale(x) } } }
|
||||
private val localesGroups by lazy { allSources.groupBy { it.locale.toLocale() } }
|
||||
|
||||
private var updateJob: Job
|
||||
|
||||
val locales = MutableStateFlow(
|
||||
FilterProperty<Locale?>(
|
||||
availableItems = listOf(null),
|
||||
selectedItems = setOf(null),
|
||||
FilterProperty<Locale>(
|
||||
availableItems = listOf(Locale.ROOT),
|
||||
selectedItems = setOf(Locale.ROOT),
|
||||
isLoading = true,
|
||||
error = null,
|
||||
),
|
||||
@@ -51,22 +52,23 @@ class WelcomeViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
updateJob = launchJob(Dispatchers.Default) {
|
||||
val languages = localesGroups.keys.associateBy { x -> x?.language }
|
||||
val selectedLocales = HashSet<Locale?>(2)
|
||||
selectedLocales += ConfigurationCompat.getLocales(context.resources.configuration).toList()
|
||||
val languages = localesGroups.keys.associateBy { x -> x.language }
|
||||
val selectedLocales = HashSet<Locale>(2)
|
||||
ConfigurationCompat.getLocales(context.resources.configuration).toList()
|
||||
.firstNotNullOfOrNull { lc -> languages[lc.language] }
|
||||
selectedLocales += null
|
||||
?.let { selectedLocales += it }
|
||||
selectedLocales += Locale.ROOT
|
||||
locales.value = locales.value.copy(
|
||||
availableItems = localesGroups.keys.sortedWithSafe(nullsFirst(LocaleComparator())),
|
||||
availableItems = localesGroups.keys.sortedWithSafe(LocaleComparator()),
|
||||
selectedItems = selectedLocales,
|
||||
isLoading = false,
|
||||
)
|
||||
repository.assimilateNewSources()
|
||||
repository.clearNewSourcesBadge()
|
||||
commit()
|
||||
}
|
||||
}
|
||||
|
||||
fun setLocaleChecked(locale: Locale?, isChecked: Boolean) {
|
||||
fun setLocaleChecked(locale: Locale, isChecked: Boolean) {
|
||||
val snapshot = locales.value
|
||||
locales.value = snapshot.copy(
|
||||
selectedItems = if (isChecked) {
|
||||
@@ -99,7 +101,7 @@ class WelcomeViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun commit() {
|
||||
val languages = locales.value.selectedItems.mapToSet { it?.language }
|
||||
val languages = locales.value.selectedItems.mapToSet { it.language }
|
||||
val types = types.value.selectedItems
|
||||
val enabledSources = allSources.filterTo(EnumSet.noneOf(MangaSource::class.java)) { x ->
|
||||
x.contentType in types && x.locale in languages
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.domain
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.util.Size
|
||||
import androidx.core.net.toFile
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -14,6 +15,8 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.local.data.isFileUri
|
||||
import org.koitharu.kotatsu.local.data.isZipUri
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
@@ -61,19 +64,28 @@ class DetectReaderModeUseCase @Inject constructor(
|
||||
val page = requireNotNull(pages.getOrNull(pageIndex)) { "No pages" }
|
||||
val url = repository.getPageUrl(page)
|
||||
val uri = Uri.parse(url)
|
||||
val size = if (uri.scheme == "cbz") {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
|
||||
val size = when {
|
||||
uri.isZipUri() -> runInterruptible(Dispatchers.IO) {
|
||||
val zip = ZipFile(uri.schemeSpecificPart)
|
||||
val entry = zip.getEntry(uri.fragment)
|
||||
zip.getInputStream(entry).use {
|
||||
getBitmapSize(it)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val request = PageLoader.createPageRequest(page, url)
|
||||
imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
getBitmapSize(it.body?.byteStream())
|
||||
|
||||
uri.isFileUri() -> runInterruptible(Dispatchers.IO) {
|
||||
uri.toFile().inputStream().use {
|
||||
getBitmapSize(it)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val request = PageLoader.createPageRequest(url, page.source)
|
||||
imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
getBitmapSize(it.body?.byteStream())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package org.koitharu.kotatsu.reader.domain
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.Point
|
||||
import android.graphics.Rect
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.graphics.alpha
|
||||
import androidx.core.graphics.blue
|
||||
import androidx.core.graphics.get
|
||||
import androidx.core.graphics.green
|
||||
import androidx.core.graphics.red
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.decoder.ImageRegionDecoder
|
||||
import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.util.ext.use
|
||||
import kotlin.math.abs
|
||||
|
||||
class EdgeDetector(private val context: Context) {
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
suspend fun getBounds(imageSource: ImageSource): Rect? = mutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
val decoder = SkiaPooledImageRegionDecoder(Bitmap.Config.RGB_565)
|
||||
try {
|
||||
val size = runInterruptible {
|
||||
decoder.init(context, imageSource)
|
||||
}
|
||||
val edges = coroutineScope {
|
||||
listOf(
|
||||
async { detectLeftRightEdge(decoder, size, isLeft = true) },
|
||||
async { detectTopBottomEdge(decoder, size, isTop = true) },
|
||||
async { detectLeftRightEdge(decoder, size, isLeft = false) },
|
||||
async { detectTopBottomEdge(decoder, size, isTop = false) },
|
||||
).awaitAll()
|
||||
}
|
||||
var hasEdges = false
|
||||
for (edge in edges) {
|
||||
if (edge > 0) {
|
||||
hasEdges = true
|
||||
} else if (edge < 0) {
|
||||
return@withContext null
|
||||
}
|
||||
}
|
||||
if (hasEdges) {
|
||||
Rect(edges[0], edges[1], size.x - edges[2], size.y - edges[3])
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} finally {
|
||||
decoder.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectLeftRightEdge(decoder: ImageRegionDecoder, size: Point, isLeft: Boolean): Int {
|
||||
var width = size.x
|
||||
val rectCount = size.x / BLOCK_SIZE
|
||||
val maxRect = rectCount / 3
|
||||
for (i in 0 until rectCount) {
|
||||
if (i > maxRect) {
|
||||
return -1
|
||||
}
|
||||
var dd = BLOCK_SIZE
|
||||
for (j in 0 until size.y / BLOCK_SIZE) {
|
||||
val regionX = if (isLeft) i * BLOCK_SIZE else size.x - (i + 1) * BLOCK_SIZE
|
||||
decoder.decodeRegion(region(regionX, j * BLOCK_SIZE), 1).use { bitmap ->
|
||||
for (ii in 0 until minOf(BLOCK_SIZE, dd)) {
|
||||
for (jj in 0 until BLOCK_SIZE) {
|
||||
val bi = if (isLeft) ii else BLOCK_SIZE - ii - 1
|
||||
if (bitmap[bi, jj].isNotWhite()) {
|
||||
width = minOf(width, BLOCK_SIZE * i + ii)
|
||||
dd--
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dd == 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (dd < BLOCK_SIZE) {
|
||||
break // We have already found vertical field or it is not exist
|
||||
}
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
private fun detectTopBottomEdge(decoder: ImageRegionDecoder, size: Point, isTop: Boolean): Int {
|
||||
var height = size.y
|
||||
val rectCount = size.y / BLOCK_SIZE
|
||||
val maxRect = rectCount / 3
|
||||
for (j in 0 until rectCount) {
|
||||
if (j > maxRect) {
|
||||
return -1
|
||||
}
|
||||
var dd = BLOCK_SIZE
|
||||
for (i in 0 until size.x / BLOCK_SIZE) {
|
||||
val regionY = if (isTop) j * BLOCK_SIZE else size.y - (j + 1) * BLOCK_SIZE
|
||||
decoder.decodeRegion(region(i * BLOCK_SIZE, regionY), 1).use { bitmap ->
|
||||
for (jj in 0 until minOf(BLOCK_SIZE, dd)) {
|
||||
for (ii in 0 until BLOCK_SIZE) {
|
||||
val bj = if (isTop) jj else BLOCK_SIZE - jj - 1
|
||||
if (bitmap[ii, bj].isNotWhite()) {
|
||||
height = minOf(height, BLOCK_SIZE * j + jj)
|
||||
dd--
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dd == 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (dd < BLOCK_SIZE) {
|
||||
break // We have already found vertical field or it is not exist
|
||||
}
|
||||
}
|
||||
return height
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val BLOCK_SIZE = 100
|
||||
private const val COLOR_TOLERANCE = 16
|
||||
|
||||
fun isColorTheSame(@ColorInt a: Int, @ColorInt b: Int, tolerance: Int): Boolean {
|
||||
return abs(a.red - b.red) <= tolerance &&
|
||||
abs(a.green - b.green) <= tolerance &&
|
||||
abs(a.blue - b.blue) <= tolerance &&
|
||||
abs(a.alpha - b.alpha) <= tolerance
|
||||
}
|
||||
|
||||
private fun Int.isNotWhite() = !isColorTheSame(this, Color.WHITE, COLOR_TOLERANCE)
|
||||
|
||||
private fun region(x: Int, y: Int) = Rect(x, y, x + BLOCK_SIZE, y + BLOCK_SIZE)
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@ package org.koitharu.kotatsu.reader.domain
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.collection.LongSparseArray
|
||||
import androidx.collection.set
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import dagger.hilt.android.ActivityRetainedLifecycle
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.scopes.ActivityRetainedScoped
|
||||
@@ -35,6 +37,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.FileSize
|
||||
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
|
||||
import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP
|
||||
import org.koitharu.kotatsu.core.util.ext.cancelChildrenAndJoin
|
||||
import org.koitharu.kotatsu.core.util.ext.compressToPNG
|
||||
import org.koitharu.kotatsu.core.util.ext.ensureRamAtLeast
|
||||
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
||||
@@ -44,6 +47,7 @@ import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode
|
||||
import org.koitharu.kotatsu.core.util.ext.isTargetNotEmpty
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.ramAvailable
|
||||
import org.koitharu.kotatsu.core.util.ext.use
|
||||
import org.koitharu.kotatsu.core.util.ext.withProgress
|
||||
import org.koitharu.kotatsu.core.util.progress.ProgressDeferred
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
@@ -51,6 +55,7 @@ import org.koitharu.kotatsu.local.data.isFileUri
|
||||
import org.koitharu.kotatsu.local.data.isZipUri
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import java.util.LinkedList
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
@@ -83,6 +88,7 @@ class PageLoader @Inject constructor(
|
||||
private val prefetchQueue = LinkedList<MangaPage>()
|
||||
private val counter = AtomicInteger(0)
|
||||
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
|
||||
private val edgeDetector = EdgeDetector(context)
|
||||
|
||||
fun isPrefetchApplicable(): Boolean {
|
||||
return repository is RemoteMangaRepository
|
||||
@@ -142,22 +148,33 @@ class PageLoader @Inject constructor(
|
||||
} else {
|
||||
val file = uri.toFile()
|
||||
context.ensureRamAtLeast(file.length() * 2)
|
||||
val image = runInterruptible(Dispatchers.IO) {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
BitmapFactory.decodeFile(file.absolutePath)
|
||||
}
|
||||
try {
|
||||
}.use { image ->
|
||||
image.compressToPNG(file)
|
||||
} finally {
|
||||
image.recycle()
|
||||
}
|
||||
uri
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getTrimmedBounds(uri: Uri): Rect? = runCatchingCancellable {
|
||||
edgeDetector.getBounds(ImageSource.Uri(uri))
|
||||
}.onFailure { error ->
|
||||
error.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
|
||||
suspend fun getPageUrl(page: MangaPage): String {
|
||||
return getRepository(page.source).getPageUrl(page)
|
||||
}
|
||||
|
||||
suspend fun invalidate(clearCache: Boolean) {
|
||||
tasks.clear()
|
||||
loaderScope.cancelChildrenAndJoin()
|
||||
if (clearCache) {
|
||||
cache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onIdle() = loaderScope.launch {
|
||||
prefetchLock.withLock {
|
||||
while (prefetchQueue.isNotEmpty()) {
|
||||
@@ -213,7 +230,7 @@ class PageLoader @Inject constructor(
|
||||
|
||||
uri.isFileUri() -> uri
|
||||
else -> {
|
||||
val request = createPageRequest(page, pageUrl)
|
||||
val request = createPageRequest(pageUrl, page.source)
|
||||
imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response ->
|
||||
val body = checkNotNull(response.body) { "Null response body" }
|
||||
body.withProgress(progress).use {
|
||||
@@ -248,12 +265,12 @@ class PageLoader @Inject constructor(
|
||||
private const val PREFETCH_LIMIT_DEFAULT = 6
|
||||
private const val PREFETCH_MIN_RAM_MB = 80L
|
||||
|
||||
fun createPageRequest(page: MangaPage, pageUrl: String) = Request.Builder()
|
||||
fun createPageRequest(pageUrl: String, mangaSource: MangaSource) = Request.Builder()
|
||||
.url(pageUrl)
|
||||
.get()
|
||||
.header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8")
|
||||
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
|
||||
.tag(MangaSource::class.java, page.source)
|
||||
.tag(MangaSource::class.java, mangaSource)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
@@ -142,7 +143,6 @@ class ReaderActivity :
|
||||
viewModel.content.observe(this) {
|
||||
onLoadingStateChanged(viewModel.isLoading.value)
|
||||
}
|
||||
viewModel.isScreenshotsBlockEnabled.observe(this, this::setWindowSecure)
|
||||
viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn)
|
||||
viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged)
|
||||
viewModel.isBookmarkAdded.observe(this, MenuInvalidator(this))
|
||||
@@ -179,6 +179,8 @@ class ReaderActivity :
|
||||
viewModel.onPause()
|
||||
}
|
||||
|
||||
override fun isNsfwContent(): Flow<Boolean> = viewModel.isMangaNsfw
|
||||
|
||||
override fun onIdle() {
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
}
|
||||
@@ -297,14 +299,6 @@ class ReaderActivity :
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun setWindowSecure(isSecure: Boolean) {
|
||||
if (isSecure) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setKeepScreenOn(isKeep: Boolean) {
|
||||
if (isKeep) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
@@ -11,7 +11,9 @@ import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.BatteryManager
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import android.view.RoundedCorner
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import androidx.annotation.AttrRes
|
||||
@@ -46,8 +48,10 @@ class ReaderInfoBarView @JvmOverloads constructor(
|
||||
private var insetLeft: Int = 0
|
||||
private var insetRight: Int = 0
|
||||
private var insetTop: Int = 0
|
||||
private var cutoutInsetLeft = 0
|
||||
private var cutoutInsetRight = 0
|
||||
private val insetLeftFallback: Int
|
||||
private val insetRightFallback: Int
|
||||
private val insetTopFallback: Int
|
||||
private val insetCornerFallback = getSystemUiDimensionOffset("rounded_corner_content_padding")
|
||||
private val colorText = ColorUtils.setAlphaComponent(
|
||||
context.getThemeColor(materialR.attr.colorOnSurface, Color.BLACK),
|
||||
200,
|
||||
@@ -80,14 +84,12 @@ class ReaderInfoBarView @JvmOverloads constructor(
|
||||
paint.strokeWidth = getDimension(R.styleable.ReaderInfoBarView_android_strokeWidth, 2f)
|
||||
paint.textSize = getDimension(R.styleable.ReaderInfoBarView_android_textSize, 16f)
|
||||
}
|
||||
val insetCorner = getSystemUiDimensionOffset("rounded_corner_content_padding")
|
||||
val fallbackInset = resources.getDimensionPixelOffset(R.dimen.reader_bar_inset_fallback)
|
||||
val insetStart = getSystemUiDimensionOffset("status_bar_padding_start", fallbackInset) + insetCorner
|
||||
val insetEnd = getSystemUiDimensionOffset("status_bar_padding_end", fallbackInset) + insetCorner
|
||||
val insetStart = getSystemUiDimensionOffset("status_bar_padding_start")
|
||||
val insetEnd = getSystemUiDimensionOffset("status_bar_padding_end")
|
||||
val isRtl = layoutDirection == LAYOUT_DIRECTION_RTL
|
||||
insetLeft = if (isRtl) insetEnd else insetStart
|
||||
insetRight = if (isRtl) insetStart else insetEnd
|
||||
insetTop = minOf(insetLeft, insetRight)
|
||||
insetLeftFallback = if (isRtl) insetEnd else insetStart
|
||||
insetRightFallback = if (isRtl) insetStart else insetEnd
|
||||
insetTopFallback = minOf(insetLeftFallback, insetRightFallback)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
@@ -110,12 +112,12 @@ class ReaderInfoBarView @JvmOverloads constructor(
|
||||
paint.textAlign = Paint.Align.LEFT
|
||||
canvas.drawTextOutline(
|
||||
text,
|
||||
(paddingLeft + insetLeft + cutoutInsetLeft).toFloat(),
|
||||
(paddingLeft + insetLeft).toFloat(),
|
||||
paddingTop + insetTop + ty,
|
||||
)
|
||||
if (isTimeVisible) {
|
||||
paint.textAlign = Paint.Align.RIGHT
|
||||
var endX = (width - paddingRight - insetRight - cutoutInsetRight).toFloat()
|
||||
var endX = (width - paddingRight - insetRight).toFloat()
|
||||
canvas.drawTextOutline(timeText, endX, paddingTop + insetTop + ty)
|
||||
if (batteryText.isNotEmpty()) {
|
||||
paint.getTextBounds(timeText, 0, timeText.length, textBounds)
|
||||
@@ -221,15 +223,29 @@ class ReaderInfoBarView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
private fun updateCutoutInsets(insetsCompat: WindowInsetsCompat?) {
|
||||
val cutouts = (insetsCompat ?: return).displayCutout?.boundingRects.orEmpty()
|
||||
cutoutInsetLeft = 0
|
||||
cutoutInsetRight = 0
|
||||
for (rect in cutouts) {
|
||||
if (rect.left <= paddingLeft) {
|
||||
cutoutInsetLeft += rect.width()
|
||||
insetLeft = insetLeftFallback
|
||||
insetRight = insetRightFallback
|
||||
insetTop = insetTopFallback
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && insetsCompat != null) {
|
||||
val nativeInsets = insetsCompat.toWindowInsets()
|
||||
nativeInsets?.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT)?.let { corner ->
|
||||
insetLeft += corner.radius
|
||||
}
|
||||
if (rect.right >= width - paddingRight) {
|
||||
cutoutInsetRight += rect.width()
|
||||
nativeInsets?.getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT)?.let { corner ->
|
||||
insetRight += corner.radius
|
||||
}
|
||||
} else {
|
||||
insetLeft += insetCornerFallback
|
||||
insetRight += insetCornerFallback
|
||||
}
|
||||
insetsCompat?.displayCutout?.let { cutout ->
|
||||
for (rect in cutout.boundingRects) {
|
||||
if (rect.left <= paddingLeft) {
|
||||
insetLeft += rect.width()
|
||||
}
|
||||
if (rect.right >= width - paddingRight) {
|
||||
insetRight += rect.width()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,13 +32,13 @@ import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||
import org.koitharu.kotatsu.core.model.findChapter
|
||||
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
@@ -70,7 +70,9 @@ private const val BOUNDS_PAGE_OFFSET = 2
|
||||
private const val PREFETCH_LIMIT = 10
|
||||
|
||||
@HiltViewModel
|
||||
class ReaderViewModel @Inject constructor(
|
||||
class ReaderViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val dataRepository: MangaDataRepository,
|
||||
private val historyRepository: HistoryRepository,
|
||||
@@ -85,7 +87,6 @@ class ReaderViewModel @Inject constructor(
|
||||
private val detectReaderModeUseCase: DetectReaderModeUseCase,
|
||||
private val statsCollector: StatsCollector,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val intent = MangaIntent(savedStateHandle)
|
||||
private val preselectedBranch = savedStateHandle.get<String>(ReaderActivity.EXTRA_BRANCH)
|
||||
|
||||
@@ -105,9 +106,11 @@ class ReaderViewModel @Inject constructor(
|
||||
|
||||
val incognitoMode = if (savedStateHandle.get<Boolean>(ReaderActivity.EXTRA_INCOGNITO) == true) {
|
||||
MutableStateFlow(true)
|
||||
} else mangaFlow.map {
|
||||
it != null && historyRepository.shouldSkip(it)
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
|
||||
} else {
|
||||
mangaFlow.map {
|
||||
it != null && historyRepository.shouldSkip(it)
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
|
||||
}
|
||||
|
||||
val isPagesSheetEnabled = observeIsPagesSheetEnabled()
|
||||
|
||||
@@ -166,13 +169,7 @@ class ReaderViewModel @Inject constructor(
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null),
|
||||
)
|
||||
|
||||
val isScreenshotsBlockEnabled = combine(
|
||||
mangaFlow,
|
||||
settings.observeAsFlow(AppSettings.KEY_SCREENSHOTS_POLICY) { screenshotsPolicy },
|
||||
) { manga, policy ->
|
||||
policy == ScreenshotsPolicy.BLOCK_ALL ||
|
||||
(policy == ScreenshotsPolicy.BLOCK_NSFW && manga != null && manga.isNsfw)
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false)
|
||||
val isMangaNsfw = mangaFlow.map { it?.isNsfw == true }
|
||||
|
||||
val isBookmarkAdded = currentState.flatMapLatest { state ->
|
||||
val manga = mangaData.value?.toManga()
|
||||
@@ -286,7 +283,9 @@ class ReaderViewModel @Inject constructor(
|
||||
prevJob?.cancelAndJoin()
|
||||
content.value = ReaderContent(emptyList(), null)
|
||||
chaptersLoader.loadSingleChapter(id)
|
||||
content.value = ReaderContent(chaptersLoader.snapshot(), ReaderState(id, page, 0))
|
||||
val newState = ReaderState(id, page, 0)
|
||||
content.value = ReaderContent(chaptersLoader.snapshot(), newState)
|
||||
saveCurrentState(newState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,17 +293,27 @@ class ReaderViewModel @Inject constructor(
|
||||
val prevJob = loadingJob
|
||||
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
val currentChapterId = currentState.requireValue().chapterId
|
||||
val allChapters = checkNotNull(manga).allChapters
|
||||
var index = allChapters.indexOfFirst { x -> x.id == currentChapterId }
|
||||
if (index < 0) {
|
||||
return@launchLoadingJob
|
||||
val prevState = currentState.requireValue()
|
||||
val newChapterId = if (delta != 0) {
|
||||
val allChapters = checkNotNull(manga).allChapters
|
||||
var index = allChapters.indexOfFirst { x -> x.id == prevState.chapterId }
|
||||
if (index < 0) {
|
||||
return@launchLoadingJob
|
||||
}
|
||||
index += delta
|
||||
(allChapters.getOrNull(index) ?: return@launchLoadingJob).id
|
||||
} else {
|
||||
prevState.chapterId
|
||||
}
|
||||
index += delta
|
||||
val newChapterId = (allChapters.getOrNull(index) ?: return@launchLoadingJob).id
|
||||
content.value = ReaderContent(emptyList(), null)
|
||||
chaptersLoader.loadSingleChapter(newChapterId)
|
||||
content.value = ReaderContent(chaptersLoader.snapshot(), ReaderState(newChapterId, 0, 0))
|
||||
val newState = ReaderState(
|
||||
chapterId = newChapterId,
|
||||
page = if (delta == 0) prevState.page else 0,
|
||||
scroll = if (delta == 0) prevState.scroll else 0,
|
||||
)
|
||||
content.value = ReaderContent(chaptersLoader.snapshot(), newState)
|
||||
saveCurrentState(newState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,9 +394,7 @@ class ReaderViewModel @Inject constructor(
|
||||
val manga = details.toManga()
|
||||
// obtain state
|
||||
if (currentState.value == null) {
|
||||
currentState.value = historyRepository.getOne(manga)?.let {
|
||||
ReaderState(it)
|
||||
} ?: ReaderState(manga, preselectedBranch ?: manga.getPreferredBranch(null))
|
||||
currentState.value = getStateFromIntent(manga)
|
||||
}
|
||||
val mode = detectReaderModeUseCase.invoke(manga, currentState.value)
|
||||
val branch = chaptersLoader.peekChapter(currentState.value?.chapterId ?: 0L)?.branch
|
||||
@@ -484,4 +491,18 @@ class ReaderViewModel @Inject constructor(
|
||||
.filter { it == AppSettings.KEY_PAGES_TAB || it == AppSettings.KEY_DETAILS_TAB || it == AppSettings.KEY_DETAILS_LAST_TAB }
|
||||
.map { settings.defaultDetailsTab == TAB_PAGES }
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.defaultDetailsTab == TAB_PAGES)
|
||||
|
||||
private suspend fun getStateFromIntent(manga: Manga): ReaderState {
|
||||
val history = historyRepository.getOne(manga)
|
||||
val result = if (history != null) {
|
||||
if (preselectedBranch != null && preselectedBranch != manga.findChapter(history.chapterId)?.branch) {
|
||||
null
|
||||
} else {
|
||||
ReaderState(history)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return result ?: ReaderState(manga, preselectedBranch ?: manga.getPreferredBranch(null))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package org.koitharu.kotatsu.reader.ui.config
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.mapToArray
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class ImageServerDelegate(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val mangaSource: MangaSource?,
|
||||
) {
|
||||
|
||||
private val repositoryLazy = SuspendLazy {
|
||||
mangaRepositoryFactory.create(checkNotNull(mangaSource)) as RemoteMangaRepository
|
||||
}
|
||||
|
||||
suspend fun isAvailable() = withContext(Dispatchers.Default) {
|
||||
repositoryLazy.tryGet().map { repository ->
|
||||
repository.getConfigKeys().any { it is ConfigKey.PreferredImageServer }
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
suspend fun getValue(): String? = withContext(Dispatchers.Default) {
|
||||
repositoryLazy.tryGet().map { repository ->
|
||||
val key = repository.getConfigKeys().firstNotNullOfOrNull { it as? ConfigKey.PreferredImageServer }
|
||||
if (key != null) {
|
||||
key.presetValues[repository.getConfig()[key]]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
suspend fun showDialog(context: Context): Boolean {
|
||||
val repository = withContext(Dispatchers.Default) {
|
||||
repositoryLazy.tryGet().getOrNull()
|
||||
} ?: return false
|
||||
val key = repository.getConfigKeys().firstNotNullOfOrNull {
|
||||
it as? ConfigKey.PreferredImageServer
|
||||
} ?: return false
|
||||
val entries = key.presetValues.values.mapToArray {
|
||||
it ?: context.getString(R.string.automatic)
|
||||
}
|
||||
val entryValues = key.presetValues.keys.toTypedArray()
|
||||
val config = repository.getConfig()
|
||||
val initialValue = config[key]
|
||||
var currentValue = initialValue
|
||||
val changed = suspendCancellableCoroutine { cont ->
|
||||
val dialog = MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.image_server)
|
||||
.setCancelable(true)
|
||||
.setSingleChoiceItems(entries, entryValues.indexOf(initialValue)) { _, i ->
|
||||
currentValue = entryValues[i]
|
||||
}.setNegativeButton(android.R.string.cancel) { dialog, _ ->
|
||||
dialog.cancel()
|
||||
}.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
if (currentValue != initialValue) {
|
||||
config[key] = currentValue
|
||||
cont.resume(true)
|
||||
} else {
|
||||
cont.resume(false)
|
||||
}
|
||||
}.setOnCancelListener {
|
||||
cont.resume(false)
|
||||
}.create()
|
||||
dialog.show()
|
||||
cont.invokeOnCancellation {
|
||||
dialog.cancel()
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
repository.invalidateCache()
|
||||
}
|
||||
return changed
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,10 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
@@ -29,6 +31,7 @@ import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
@@ -47,7 +50,14 @@ class ReaderConfigSheet :
|
||||
@Inject
|
||||
lateinit var orientationHelper: ScreenOrientationHelper
|
||||
|
||||
@Inject
|
||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var pageLoader: PageLoader
|
||||
|
||||
private lateinit var mode: ReaderMode
|
||||
private lateinit var imageServerDelegate: ImageServerDelegate
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
@@ -57,6 +67,10 @@ class ReaderConfigSheet :
|
||||
mode = arguments?.getInt(ARG_MODE)
|
||||
?.let { ReaderMode.valueOf(it) }
|
||||
?: ReaderMode.STANDARD
|
||||
imageServerDelegate = ImageServerDelegate(
|
||||
mangaRepositoryFactory = mangaRepositoryFactory,
|
||||
mangaSource = viewModel.manga?.toManga()?.source,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
@@ -83,11 +97,20 @@ class ReaderConfigSheet :
|
||||
binding.buttonSavePage.setOnClickListener(this)
|
||||
binding.buttonScreenRotate.setOnClickListener(this)
|
||||
binding.buttonSettings.setOnClickListener(this)
|
||||
binding.buttonImageServer.setOnClickListener(this)
|
||||
binding.buttonColorFilter.setOnClickListener(this)
|
||||
binding.sliderTimer.addOnChangeListener(this)
|
||||
binding.switchScrollTimer.setOnCheckedChangeListener(this)
|
||||
binding.switchDoubleReader.setOnCheckedChangeListener(this)
|
||||
|
||||
viewLifecycleScope.launch {
|
||||
val isAvailable = imageServerDelegate.isAvailable()
|
||||
if (isAvailable) {
|
||||
bindImageServerTitle()
|
||||
}
|
||||
binding.buttonImageServer.isVisible = isAvailable
|
||||
}
|
||||
|
||||
settings.observeAsStateFlow(
|
||||
scope = lifecycleScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_READER_AUTOSCROLL_SPEED,
|
||||
@@ -124,6 +147,14 @@ class ReaderConfigSheet :
|
||||
val manga = viewModel.manga?.toManga() ?: return
|
||||
startActivity(ColorFilterConfigActivity.newIntent(v.context, manga, page))
|
||||
}
|
||||
|
||||
R.id.button_image_server -> viewLifecycleScope.launch {
|
||||
if (imageServerDelegate.showDialog(v.context)) {
|
||||
bindImageServerTitle()
|
||||
pageLoader.invalidate(clearCache = true)
|
||||
viewModel.switchChapterBy(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +225,14 @@ class ReaderConfigSheet :
|
||||
switch.setOnCheckedChangeListener(this)
|
||||
}
|
||||
|
||||
private suspend fun bindImageServerTitle() {
|
||||
viewBinding?.buttonImageServer?.text = getString(
|
||||
R.string.inline_preference_pattern,
|
||||
getString(R.string.image_server),
|
||||
imageServerDelegate.getValue() ?: getString(R.string.automatic),
|
||||
)
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
|
||||
var isAutoScrollEnabled: Boolean
|
||||
|
||||
@@ -18,6 +18,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
||||
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
|
||||
|
||||
@@ -54,6 +55,10 @@ class ReaderSettings(
|
||||
view.background = bg.resolve(view.context)
|
||||
}
|
||||
|
||||
fun isPagesCropEnabled(isWebtoon: Boolean) = settings.isPagesCropEnabled(
|
||||
if (isWebtoon) ReaderMode.WEBTOON else ReaderMode.STANDARD,
|
||||
)
|
||||
|
||||
@CheckResult
|
||||
fun applyBitmapConfig(ssiv: SubsamplingScaleImageView): Boolean {
|
||||
val config = bitmapConfig
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
import org.koitharu.kotatsu.reader.ui.pager.PageHolderDelegate.State
|
||||
import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonHolder
|
||||
|
||||
abstract class BasePageHolder<B : ViewBinding>(
|
||||
protected val binding: B,
|
||||
@@ -24,7 +25,14 @@ abstract class BasePageHolder<B : ViewBinding>(
|
||||
) : LifecycleAwareViewHolder(binding.root, lifecycleOwner), PageHolderDelegate.Callback {
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
protected val delegate = PageHolderDelegate(loader, settings, this, networkState, exceptionResolver)
|
||||
protected val delegate = PageHolderDelegate(
|
||||
loader = loader,
|
||||
readerSettings = settings,
|
||||
callback = this,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
isWebtoon = this is WebtoonHolder,
|
||||
)
|
||||
protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root)
|
||||
|
||||
val context: Context
|
||||
@@ -70,8 +78,8 @@ abstract class BasePageHolder<B : ViewBinding>(
|
||||
delegate.onRecycle()
|
||||
}
|
||||
|
||||
protected fun SubsamplingScaleImageView.applyDownsampling(isForeground: Boolean) {
|
||||
downsampling = when {
|
||||
protected fun SubsamplingScaleImageView.applyDownSampling(isForeground: Boolean) {
|
||||
downSampling = when {
|
||||
isForeground || !settings.isReaderOptimizationEnabled -> 1
|
||||
context.isLowRamDevice() -> 8
|
||||
else -> 4
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.Observer
|
||||
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
|
||||
@@ -32,6 +33,7 @@ class PageHolderDelegate(
|
||||
private val callback: Callback,
|
||||
private val networkState: NetworkState,
|
||||
private val exceptionResolver: ExceptionResolver,
|
||||
private val isWebtoon: Boolean,
|
||||
) : DefaultOnImageEventListener, Observer<ReaderSettings> {
|
||||
|
||||
private val scope = loader.loaderScope + Dispatchers.Main.immediate
|
||||
@@ -39,6 +41,7 @@ class PageHolderDelegate(
|
||||
private set
|
||||
private var job: Job? = null
|
||||
private var uri: Uri? = null
|
||||
private var cachedBounds: Rect? = null
|
||||
private var error: Throwable? = null
|
||||
|
||||
init {
|
||||
@@ -88,6 +91,7 @@ class PageHolderDelegate(
|
||||
fun onRecycle() {
|
||||
state = State.EMPTY
|
||||
uri = null
|
||||
cachedBounds = null
|
||||
error = null
|
||||
job?.cancel()
|
||||
}
|
||||
@@ -95,7 +99,7 @@ class PageHolderDelegate(
|
||||
fun reload() {
|
||||
if (state == State.SHOWN) {
|
||||
uri?.let {
|
||||
callback.onImageReady(it)
|
||||
callback.onImageReady(it, cachedBounds)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,8 +142,13 @@ class PageHolderDelegate(
|
||||
state = State.CONVERTING
|
||||
try {
|
||||
val newUri = loader.convertBimap(uri)
|
||||
cachedBounds = if (readerSettings.isPagesCropEnabled(isWebtoon)) {
|
||||
loader.getTrimmedBounds(newUri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
state = State.CONVERTED
|
||||
callback.onImageReady(newUri)
|
||||
callback.onImageReady(newUri, cachedBounds)
|
||||
} catch (ce: CancellationException) {
|
||||
throw ce
|
||||
} catch (e2: Throwable) {
|
||||
@@ -166,7 +175,12 @@ class PageHolderDelegate(
|
||||
file
|
||||
}
|
||||
state = State.LOADED
|
||||
callback.onImageReady(checkNotNull(uri))
|
||||
cachedBounds = if (readerSettings.isPagesCropEnabled(isWebtoon)) {
|
||||
loader.getTrimmedBounds(checkNotNull(uri))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
callback.onImageReady(checkNotNull(uri), cachedBounds)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
@@ -196,7 +210,7 @@ class PageHolderDelegate(
|
||||
|
||||
fun onError(e: Throwable)
|
||||
|
||||
fun onImageReady(uri: Uri)
|
||||
fun onImageReady(uri: Uri, bounds: Rect?)
|
||||
|
||||
fun onImageShowing(settings: ReaderSettings)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.reader.ui.pager.standard
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.PointF
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
@@ -46,12 +47,12 @@ open class PageHolder(
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
binding.ssiv.applyDownsampling(isForeground = true)
|
||||
binding.ssiv.applyDownSampling(isForeground = true)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
binding.ssiv.applyDownsampling(isForeground = false)
|
||||
binding.ssiv.applyDownSampling(isForeground = false)
|
||||
}
|
||||
|
||||
override fun onConfigChanged() {
|
||||
@@ -59,7 +60,7 @@ open class PageHolder(
|
||||
if (settings.applyBitmapConfig(binding.ssiv)) {
|
||||
delegate.reload()
|
||||
}
|
||||
binding.ssiv.applyDownsampling(isResumed())
|
||||
binding.ssiv.applyDownSampling(isResumed())
|
||||
binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled
|
||||
}
|
||||
|
||||
@@ -89,8 +90,12 @@ open class PageHolder(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onImageReady(uri: Uri) {
|
||||
binding.ssiv.setImage(ImageSource.Uri(uri))
|
||||
override fun onImageReady(uri: Uri, bounds: Rect?) {
|
||||
val source = ImageSource.Uri(uri)
|
||||
if (bounds != null) {
|
||||
source.region(bounds)
|
||||
}
|
||||
binding.ssiv.setImage(source)
|
||||
}
|
||||
|
||||
override fun onImageShowing(settings: ReaderSettings) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import androidx.core.view.isVisible
|
||||
@@ -39,12 +40,12 @@ class WebtoonHolder(
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
binding.ssiv.applyDownsampling(isForeground = true)
|
||||
binding.ssiv.applyDownSampling(isForeground = true)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
binding.ssiv.applyDownsampling(isForeground = false)
|
||||
binding.ssiv.applyDownSampling(isForeground = false)
|
||||
}
|
||||
|
||||
override fun onConfigChanged() {
|
||||
@@ -52,7 +53,7 @@ class WebtoonHolder(
|
||||
if (settings.applyBitmapConfig(binding.ssiv)) {
|
||||
delegate.reload()
|
||||
}
|
||||
binding.ssiv.applyDownsampling(isResumed())
|
||||
binding.ssiv.applyDownSampling(isResumed())
|
||||
}
|
||||
|
||||
override fun onBind(data: ReaderPage) {
|
||||
@@ -89,8 +90,12 @@ class WebtoonHolder(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onImageReady(uri: Uri) {
|
||||
binding.ssiv.setImage(ImageSource.Uri(uri))
|
||||
override fun onImageReady(uri: Uri, bounds: Rect?) {
|
||||
val source = ImageSource.Uri(uri)
|
||||
if (bounds != null) {
|
||||
source.region(bounds)
|
||||
}
|
||||
binding.ssiv.setImage(source)
|
||||
}
|
||||
|
||||
override fun onImageShowing(settings: ReaderSettings) {
|
||||
|
||||
@@ -97,8 +97,8 @@ class WebtoonImageView @JvmOverloads constructor(
|
||||
setMeasuredDimension(desiredWidth, desiredHeight)
|
||||
}
|
||||
|
||||
override fun onDownsamplingChanged() {
|
||||
super.onDownsamplingChanged()
|
||||
override fun onDownSamplingChanged() {
|
||||
super.onDownSamplingChanged()
|
||||
post {
|
||||
adjustScale()
|
||||
}
|
||||
|
||||
@@ -221,7 +221,14 @@ class WebtoonScalingFrame @JvmOverloads constructor(
|
||||
syncMatrixValues()
|
||||
}
|
||||
|
||||
private fun scaleChild(newScale: Float, focusX: Float, focusY: Float) {
|
||||
private fun scaleChild(
|
||||
newScale: Float,
|
||||
focusX: Float,
|
||||
focusY: Float,
|
||||
): Boolean {
|
||||
if (scale.isNaN() || scale == 0f) {
|
||||
return false
|
||||
}
|
||||
val factor = newScale / scale
|
||||
if (newScale > 1) {
|
||||
translateBounds.set(
|
||||
@@ -240,13 +247,12 @@ class WebtoonScalingFrame @JvmOverloads constructor(
|
||||
}
|
||||
transformMatrix.postScale(factor, factor, focusX, focusY)
|
||||
invalidateTarget()
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
||||
val newScale = (scale * detector.scaleFactor).coerceIn(MIN_SCALE, MAX_SCALE)
|
||||
scaleChild(newScale, detector.focusX, detector.focusY)
|
||||
return true
|
||||
return scaleChild(newScale, detector.focusX, detector.focusY)
|
||||
}
|
||||
|
||||
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.core.util.ext.sizeOrZero
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.explore.domain.ExploreRepository
|
||||
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
||||
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
||||
@@ -45,7 +46,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.util.concatUrl
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val FILTER_MIN_INTERVAL = 250L
|
||||
@@ -59,6 +59,7 @@ open class RemoteListViewModel @Inject constructor(
|
||||
listExtraProvider: ListExtraProvider,
|
||||
downloadScheduler: DownloadWorker.Scheduler,
|
||||
private val exploreRepository: ExploreRepository,
|
||||
sourcesRepository: MangaSourcesRepository,
|
||||
) : MangaListViewModel(settings, downloadScheduler), MangaFilter by filter {
|
||||
|
||||
val source = savedStateHandle.require<MangaSource>(RemoteListFragment.ARG_SOURCE)
|
||||
@@ -117,6 +118,10 @@ open class RemoteListViewModel @Inject constructor(
|
||||
}.catch { error ->
|
||||
listError.value = error
|
||||
}.launchIn(viewModelScope)
|
||||
|
||||
launchJob(Dispatchers.Default) {
|
||||
sourcesRepository.trackUsage(source)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
|
||||
@@ -51,7 +51,7 @@ abstract class Scrobbler(
|
||||
}
|
||||
}
|
||||
|
||||
val isAvailable: Boolean
|
||||
val isEnabled: Boolean
|
||||
get() = repository.isAuthorized
|
||||
|
||||
suspend fun authorize(authCode: String): ScrobblerUser {
|
||||
|
||||
@@ -42,7 +42,7 @@ class ScrobblingSelectorViewModel @Inject constructor(
|
||||
|
||||
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
|
||||
|
||||
val availableScrobblers = scrobblers.filter { it.isAvailable }
|
||||
val availableScrobblers = scrobblers.filter { it.isEnabled }
|
||||
|
||||
val selectedScrobblerIndex = MutableStateFlow(0)
|
||||
|
||||
|
||||
@@ -125,6 +125,8 @@ class MangaSearchRepository @Inject constructor(
|
||||
return db.getTagsDao().findRareTags(source.name, limit).toMangaTagsList()
|
||||
}
|
||||
|
||||
suspend fun getSourcesSuggestion(limit: Int): List<MangaSource> = sourcesRepository.getTopSources(limit)
|
||||
|
||||
fun getSourcesSuggestion(query: String, limit: Int): List<MangaSource> {
|
||||
if (query.length < 3) {
|
||||
return emptyList()
|
||||
|
||||
@@ -15,11 +15,14 @@ import androidx.fragment.app.commit
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
@@ -58,6 +61,8 @@ class MangaListActivity :
|
||||
"Cannot find FilterOwner fragment in ${supportFragmentManager.fragments}"
|
||||
}.filter
|
||||
|
||||
private var source: MangaSource? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityMangaListBinding.inflate(layoutInflater))
|
||||
@@ -66,16 +71,19 @@ class MangaListActivity :
|
||||
if (viewBinding.containerFilterHeader != null) {
|
||||
viewBinding.appbar.addOnOffsetChangedListener(this)
|
||||
}
|
||||
val source = intent.getStringExtra(EXTRA_SOURCE)?.let(::MangaSource) ?: tags?.firstOrNull()?.source
|
||||
if (source == null) {
|
||||
source = intent.getStringExtra(EXTRA_SOURCE)?.let(::MangaSource) ?: tags?.firstOrNull()?.source
|
||||
val src = source
|
||||
if (src == null) {
|
||||
finishAfterTransition()
|
||||
return
|
||||
} else {
|
||||
viewBinding.buttonOrder?.setOnClickListener(this)
|
||||
title = if (src == MangaSource.LOCAL) getString(R.string.local_storage) else src.title
|
||||
initList(src, tags)
|
||||
}
|
||||
viewBinding.buttonOrder?.setOnClickListener(this)
|
||||
title = if (source == MangaSource.LOCAL) getString(R.string.local_storage) else source.title
|
||||
initList(source, tags)
|
||||
}
|
||||
|
||||
override fun isNsfwContent(): Flow<Boolean> = flowOf(source?.isNsfw() == true)
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.root.updatePadding(
|
||||
left = insets.left,
|
||||
|
||||
@@ -37,6 +37,7 @@ private const val MAX_HINTS_ITEMS = 3
|
||||
private const val MAX_AUTHORS_ITEMS = 2
|
||||
private const val MAX_TAGS_ITEMS = 8
|
||||
private const val MAX_SOURCES_ITEMS = 6
|
||||
private const val MAX_SOURCES_TIPS_ITEMS = 2
|
||||
|
||||
@HiltViewModel
|
||||
class SearchSuggestionViewModel @Inject constructor(
|
||||
@@ -149,12 +150,18 @@ class SearchSuggestionViewModel @Inject constructor(
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val sourcesTipsDeferred = if (searchQuery.isEmpty() && SearchSuggestionType.RECENT_SOURCES in types) {
|
||||
async { repository.getSourcesSuggestion(MAX_SOURCES_TIPS_ITEMS) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val tags = tagsDeferred?.await()
|
||||
val mangaList = mangaDeferred?.await()
|
||||
val queries = queriesDeferred?.await()
|
||||
val hints = hintsDeferred?.await()
|
||||
val authors = authorsDeferred?.await()
|
||||
val sourcesTips = sourcesTipsDeferred?.await()
|
||||
|
||||
buildList(queries.sizeOrZero() + sources.sizeOrZero() + authors.sizeOrZero() + hints.sizeOrZero() + 2) {
|
||||
if (!tags.isNullOrEmpty()) {
|
||||
@@ -167,17 +174,14 @@ class SearchSuggestionViewModel @Inject constructor(
|
||||
queries?.mapTo(this) { SearchSuggestionItem.RecentQuery(it) }
|
||||
authors?.mapTo(this) { SearchSuggestionItem.Author(it) }
|
||||
hints?.mapTo(this) { SearchSuggestionItem.Hint(it) }
|
||||
sourcesTips?.mapTo(this) { SearchSuggestionItem.SourceTip(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapTags(tags: List<MangaTag>): List<ChipsView.ChipModel> = tags.map { tag ->
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = tag.title,
|
||||
icon = 0,
|
||||
data = tag,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ class SearchSuggestionAdapter(
|
||||
delegatesManager
|
||||
.addDelegate(SEARCH_SUGGESTION_ITEM_TYPE_QUERY, searchSuggestionQueryAD(listener))
|
||||
.addDelegate(searchSuggestionSourceAD(coil, lifecycleOwner, listener))
|
||||
.addDelegate(searchSuggestionSourceTipAD(coil, lifecycleOwner, listener))
|
||||
.addDelegate(searchSuggestionTagsAD(listener))
|
||||
.addDelegate(searchSuggestionMangaListAD(coil, lifecycleOwner, listener))
|
||||
.addDelegate(searchSuggestionQueryHintAD(listener))
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.koitharu.kotatsu.search.ui.suggestion.adapter
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.getSummary
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||
import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.databinding.ItemSearchSuggestionSourceTipBinding
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
|
||||
|
||||
fun searchSuggestionSourceTipAD(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
listener: SearchSuggestionListener,
|
||||
) =
|
||||
adapterDelegateViewBinding<SearchSuggestionItem.SourceTip, SearchSuggestionItem, ItemSearchSuggestionSourceTipBinding>(
|
||||
{ inflater, parent -> ItemSearchSuggestionSourceTipBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
|
||||
binding.root.setOnClickListener {
|
||||
listener.onSourceClick(item.source)
|
||||
}
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.source.getTitle(context)
|
||||
binding.textViewSubtitle.text = item.source.getSummary(context)
|
||||
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
|
||||
binding.imageViewCover.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
|
||||
fallback(fallbackIcon)
|
||||
placeholder(fallbackIcon)
|
||||
error(fallbackIcon)
|
||||
source(item.source)
|
||||
enqueueWith(coil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.search.ui.suggestion.model
|
||||
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
@@ -69,6 +70,18 @@ sealed interface SearchSuggestionItem : ListModel {
|
||||
}
|
||||
}
|
||||
|
||||
data class SourceTip(
|
||||
val source: MangaSource,
|
||||
) : SearchSuggestionItem {
|
||||
|
||||
val isNsfw: Boolean
|
||||
get() = source.isNsfw()
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
return other is Source && other.source == source
|
||||
}
|
||||
}
|
||||
|
||||
data class Tags(
|
||||
val tags: List<ChipsView.ChipModel>,
|
||||
) : SearchSuggestionItem {
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.MultiSelectListPreference
|
||||
import androidx.preference.Preference
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -17,6 +18,7 @@ import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
|
||||
import org.koitharu.kotatsu.parsers.util.names
|
||||
import org.koitharu.kotatsu.settings.reader.ReaderTapGridConfigActivity
|
||||
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
|
||||
import org.koitharu.kotatsu.settings.utils.PercentSummaryProvider
|
||||
import org.koitharu.kotatsu.settings.utils.SliderPreference
|
||||
|
||||
@@ -48,6 +50,9 @@ class ReaderSettingsFragment :
|
||||
entryValues = ZoomMode.entries.names()
|
||||
setDefaultValueCompat(ZoomMode.FIT_CENTER.name)
|
||||
}
|
||||
findPreference<MultiSelectListPreference>(AppSettings.KEY_READER_CROP)?.run {
|
||||
summaryProvider = MultiSummaryProvider(R.string.disabled)
|
||||
}
|
||||
findPreference<SliderPreference>(AppSettings.KEY_WEBTOON_ZOOM_OUT)?.summaryProvider = PercentSummaryProvider()
|
||||
updateReaderModeDependency()
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings.newsources
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import coil.ImageLoader
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.databinding.DialogOnboardBinding
|
||||
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class NewSourcesDialogFragment :
|
||||
AlertDialogFragment<DialogOnboardBinding>(),
|
||||
SourceConfigListener,
|
||||
DialogInterface.OnClickListener {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private val viewModel by viewModels<NewSourcesViewModel>()
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
): DialogOnboardBinding {
|
||||
return DialogOnboardBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: DialogOnboardBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
val adapter = SourcesSelectAdapter(this, coil, viewLifecycleOwner)
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.textViewTitle.setText(R.string.new_sources_text)
|
||||
|
||||
viewModel.content.observe(viewLifecycleOwner) { adapter.items = it }
|
||||
}
|
||||
|
||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||
return super.onBuildDialog(builder)
|
||||
.setPositiveButton(R.string.done, this)
|
||||
.setCancelable(true)
|
||||
.setTitle(R.string.remote_sources)
|
||||
}
|
||||
|
||||
override fun onClick(dialog: DialogInterface, which: Int) {
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) = Unit
|
||||
|
||||
override fun onItemLiftClick(item: SourceConfigItem.SourceItem) = Unit
|
||||
|
||||
override fun onItemShortcutClick(item: SourceConfigItem.SourceItem) = Unit
|
||||
|
||||
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
|
||||
viewModel.onItemEnabledChanged(item, isEnabled)
|
||||
}
|
||||
|
||||
override fun onCloseTip(tip: SourceConfigItem.Tip) = Unit
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "NewSources"
|
||||
|
||||
fun show(fm: FragmentManager) = NewSourcesDialogFragment().show(fm, TAG)
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings.newsources
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class NewSourcesViewModel @Inject constructor(
|
||||
private val repository: MangaSourcesRepository,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val newSources = SuspendLazy {
|
||||
repository.assimilateNewSources()
|
||||
}
|
||||
val content: StateFlow<List<SourceConfigItem>> = repository.observeAll()
|
||||
.map { sources ->
|
||||
val new = newSources.get()
|
||||
val skipNsfw = settings.isNsfwContentDisabled
|
||||
sources.mapNotNull { (source, enabled) ->
|
||||
if (source in new) {
|
||||
SourceConfigItem.SourceItem(
|
||||
source = source,
|
||||
isEnabled = enabled,
|
||||
isDraggable = false,
|
||||
isAvailable = !skipNsfw || source.contentType != ContentType.HENTAI,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
|
||||
|
||||
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
repository.setSourcesEnabled(setOf(item.source), isEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings.newsources
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
|
||||
import org.koitharu.kotatsu.settings.sources.adapter.sourceConfigItemCheckableDelegate
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
|
||||
class SourcesSelectAdapter(
|
||||
listener: SourceConfigListener,
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) : BaseListAdapter<SourceConfigItem>() {
|
||||
|
||||
init {
|
||||
delegatesManager.addDelegate(sourceConfigItemCheckableDelegate(listener, coil, lifecycleOwner))
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ package org.koitharu.kotatsu.settings.sources
|
||||
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.mapToArray
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import org.koitharu.kotatsu.settings.utils.AutoCompleteTextViewPreference
|
||||
@@ -23,9 +25,9 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang
|
||||
is ConfigKey.Domain -> {
|
||||
val presetValues = key.presetValues
|
||||
if (presetValues.size <= 1) {
|
||||
EditTextPreference(requireContext())
|
||||
EditTextPreference(screen.context)
|
||||
} else {
|
||||
AutoCompleteTextViewPreference(requireContext()).apply {
|
||||
AutoCompleteTextViewPreference(screen.context).apply {
|
||||
entries = presetValues.toStringArray()
|
||||
}
|
||||
}.apply {
|
||||
@@ -43,7 +45,7 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang
|
||||
}
|
||||
|
||||
is ConfigKey.UserAgent -> {
|
||||
AutoCompleteTextViewPreference(requireContext()).apply {
|
||||
AutoCompleteTextViewPreference(screen.context).apply {
|
||||
entries = arrayOf(
|
||||
UserAgents.FIREFOX_MOBILE,
|
||||
UserAgents.CHROME_MOBILE,
|
||||
@@ -64,19 +66,32 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang
|
||||
}
|
||||
|
||||
is ConfigKey.ShowSuspiciousContent -> {
|
||||
SwitchPreferenceCompat(requireContext()).apply {
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
setDefaultValue(key.defaultValue)
|
||||
setTitle(R.string.show_suspicious_content)
|
||||
}
|
||||
}
|
||||
|
||||
is ConfigKey.SplitByTranslations -> {
|
||||
SwitchPreferenceCompat(requireContext()).apply {
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
setDefaultValue(key.defaultValue)
|
||||
setTitle(R.string.split_by_translations)
|
||||
setSummary(R.string.split_by_translations_summary)
|
||||
}
|
||||
}
|
||||
|
||||
is ConfigKey.PreferredImageServer -> {
|
||||
ListPreference(screen.context).apply {
|
||||
entries = key.presetValues.values.mapToArray {
|
||||
it ?: context.getString(R.string.automatic)
|
||||
}
|
||||
entryValues = key.presetValues.keys.mapToArray { it.orEmpty() }
|
||||
setDefaultValue(key.defaultValue.orEmpty())
|
||||
setTitle(R.string.image_server)
|
||||
setDialogTitle(R.string.image_server)
|
||||
summaryProvider = ListPreference.SimpleSummaryProvider.getInstance()
|
||||
}
|
||||
}
|
||||
}
|
||||
preference.isIconSpaceReserved = false
|
||||
preference.key = key.key
|
||||
|
||||
@@ -36,7 +36,7 @@ class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenc
|
||||
addPreferencesFromRepository(viewModel.repository)
|
||||
|
||||
findPreference<SwitchPreferenceCompat>(KEY_ENABLE)?.run {
|
||||
setOnPreferenceChangeListener(this@SourceSettingsFragment)
|
||||
onPreferenceChangeListener = this@SourceSettingsFragment
|
||||
}
|
||||
findPreference<Preference>(KEY_AUTH)?.run {
|
||||
val authProvider = viewModel.repository.getAuthProvider()
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.settings.sources.adapter
|
||||
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
@@ -16,49 +17,14 @@ import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||
import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
|
||||
import org.koitharu.kotatsu.core.ui.list.OnTipCloseListener
|
||||
import org.koitharu.kotatsu.core.util.ext.crossfade
|
||||
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.source
|
||||
import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemSourceConfigCheckableBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemTipBinding
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
|
||||
fun sourceConfigItemCheckableDelegate(
|
||||
listener: SourceConfigListener,
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) = adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigCheckableBinding>(
|
||||
{ layoutInflater, parent ->
|
||||
ItemSourceConfigCheckableBinding.inflate(
|
||||
layoutInflater,
|
||||
parent,
|
||||
false,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
||||
binding.switchToggle.setOnCheckedChangeListener { _, isChecked ->
|
||||
listener.onItemEnabledChanged(item, isChecked)
|
||||
}
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.source.getTitle(context)
|
||||
binding.switchToggle.isChecked = item.isEnabled
|
||||
binding.switchToggle.isEnabled = item.isAvailable
|
||||
binding.textViewDescription.text = item.source.getSummary(context)
|
||||
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
|
||||
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
|
||||
crossfade(context)
|
||||
error(fallbackIcon)
|
||||
placeholder(fallbackIcon)
|
||||
fallback(fallbackIcon)
|
||||
source(item.source)
|
||||
enqueueWith(coil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sourceConfigItemDelegate2(
|
||||
listener: SourceConfigListener,
|
||||
coil: ImageLoader,
|
||||
@@ -73,6 +39,7 @@ fun sourceConfigItemDelegate2(
|
||||
},
|
||||
) {
|
||||
|
||||
val iconPinned = ContextCompat.getDrawable(context, R.drawable.ic_pin_small)
|
||||
val eventListener = View.OnClickListener { v ->
|
||||
when (v.id) {
|
||||
R.id.imageView_add -> listener.onItemEnabledChanged(item, true)
|
||||
@@ -89,6 +56,7 @@ fun sourceConfigItemDelegate2(
|
||||
binding.imageViewAdd.isGone = item.isEnabled || !item.isAvailable
|
||||
binding.imageViewRemove.isVisible = item.isEnabled
|
||||
binding.imageViewMenu.isVisible = item.isEnabled
|
||||
binding.textViewTitle.drawableStart = if (item.isPinned) iconPinned else null
|
||||
binding.textViewDescription.text = item.source.getSummary(context)
|
||||
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
|
||||
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
|
||||
@@ -132,12 +100,15 @@ private fun showSourceMenu(
|
||||
menu.inflate(R.menu.popup_source_config)
|
||||
menu.menu.findItem(R.id.action_shortcut)
|
||||
?.isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(anchor.context)
|
||||
menu.menu.findItem(R.id.action_pin)?.isVisible = item.isEnabled
|
||||
menu.menu.findItem(R.id.action_pin)?.isChecked = item.isPinned
|
||||
menu.menu.findItem(R.id.action_lift)?.isVisible = item.isDraggable
|
||||
menu.setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.action_settings -> listener.onItemSettingsClick(item)
|
||||
R.id.action_lift -> listener.onItemLiftClick(item)
|
||||
R.id.action_shortcut -> listener.onItemShortcutClick(item)
|
||||
R.id.action_pin -> listener.onItemPinClick(item)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -11,5 +11,7 @@ interface SourceConfigListener : OnTipCloseListener<SourceConfigItem.Tip> {
|
||||
|
||||
fun onItemShortcutClick(item: SourceConfigItem.SourceItem)
|
||||
|
||||
fun onItemPinClick(item: SourceConfigItem.SourceItem)
|
||||
|
||||
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ sealed interface SourceCatalogItem : ListModel {
|
||||
|
||||
data class Source(
|
||||
val source: MangaSource,
|
||||
val showSummary: Boolean,
|
||||
) : SourceCatalogItem {
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.settings.sources.catalog
|
||||
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
@@ -15,6 +16,7 @@ import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.crossfade
|
||||
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.setTextAndVisible
|
||||
@@ -22,28 +24,32 @@ import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.databinding.ItemCatalogPageBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemEmptyHintBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemSourceCatalogBinding
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
fun sourceCatalogItemSourceAD(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
listener: OnListItemClickListener<SourceCatalogItem.Source>
|
||||
) = adapterDelegateViewBinding<SourceCatalogItem.Source, SourceCatalogItem, ItemSourceCatalogBinding>(
|
||||
) = adapterDelegateViewBinding<SourceCatalogItem.Source, ListModel, ItemSourceCatalogBinding>(
|
||||
{ layoutInflater, parent ->
|
||||
ItemSourceCatalogBinding.inflate(layoutInflater, parent, false)
|
||||
},
|
||||
) {
|
||||
|
||||
binding.imageViewAdd.setOnClickListener { v ->
|
||||
listener.onItemLongClick(item, v)
|
||||
}
|
||||
binding.root.setOnClickListener { v ->
|
||||
listener.onItemClick(item, v)
|
||||
}
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.source.getTitle(context)
|
||||
if (item.showSummary) {
|
||||
binding.textViewDescription.text = item.source.getSummary(context)
|
||||
binding.textViewDescription.isVisible = true
|
||||
binding.textViewDescription.text = item.source.getSummary(context)
|
||||
binding.textViewDescription.drawableStart = if (item.source.isBroken) {
|
||||
ContextCompat.getDrawable(context, R.drawable.ic_off_small)
|
||||
} else {
|
||||
binding.textViewDescription.isVisible = false
|
||||
null
|
||||
}
|
||||
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
|
||||
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
|
||||
@@ -61,7 +67,7 @@ fun sourceCatalogItemSourceAD(
|
||||
fun sourceCatalogItemHintAD(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) = adapterDelegateViewBinding<SourceCatalogItem.Hint, SourceCatalogItem, ItemEmptyHintBinding>(
|
||||
) = adapterDelegateViewBinding<SourceCatalogItem.Hint, ListModel, ItemEmptyHintBinding>(
|
||||
{ inflater, parent -> ItemEmptyHintBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
|
||||
|
||||
@@ -1,41 +1,46 @@
|
||||
package org.koitharu.kotatsu.settings.sources.catalog
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import coil.ImageLoader
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.google.android.material.chip.Chip
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.titleResId
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView.ChipModel
|
||||
import org.koitharu.kotatsu.core.util.LocaleComparator
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||
import org.koitharu.kotatsu.databinding.ActivitySourcesCatalogBinding
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
|
||||
OnListItemClickListener<SourceCatalogItem.Source>,
|
||||
AppBarOwner, MenuItem.OnActionExpandListener {
|
||||
AppBarOwner, MenuItem.OnActionExpandListener, ChipsView.OnChipClickListener {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private var newSourcesSnackbar: Snackbar? = null
|
||||
|
||||
override val appBar: AppBarLayout
|
||||
get() = viewBinding.appbar
|
||||
|
||||
@@ -45,18 +50,20 @@ class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivitySourcesCatalogBinding.inflate(layoutInflater))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
val pagerAdapter = SourcesCatalogPagerAdapter(this, coil, this)
|
||||
viewBinding.pager.adapter = pagerAdapter
|
||||
val tabMediator = TabLayoutMediator(viewBinding.tabs, viewBinding.pager, pagerAdapter)
|
||||
tabMediator.attach()
|
||||
viewModel.content.observe(this, pagerAdapter)
|
||||
viewModel.hasNewSources.observe(this, ::onHasNewSourcesChanged)
|
||||
val sourcesAdapter = SourcesCatalogAdapter(this, coil, this)
|
||||
with(viewBinding.recyclerView) {
|
||||
setHasFixedSize(true)
|
||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||
adapter = sourcesAdapter
|
||||
}
|
||||
viewBinding.chipsFilter.onChipClickListener = this
|
||||
viewModel.content.observe(this, sourcesAdapter)
|
||||
viewModel.onActionDone.observeEvent(
|
||||
this,
|
||||
ReversibleActionObserver(viewBinding.pager),
|
||||
ReversibleActionObserver(viewBinding.recyclerView),
|
||||
)
|
||||
viewModel.locale.observe(this) {
|
||||
supportActionBar?.subtitle = it?.toLocale().getDisplayName(this)
|
||||
combine(viewModel.appliedFilter, viewModel.hasNewSources, ::Pair).observe(this) {
|
||||
updateFilers(it.first, it.second)
|
||||
}
|
||||
addMenuProvider(SourcesCatalogMenuProvider(this, viewModel, this))
|
||||
}
|
||||
@@ -66,51 +73,85 @@ class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
)
|
||||
viewBinding.recyclerView.updatePadding(
|
||||
bottom = insets.bottom,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onChipClick(chip: Chip, data: Any?) {
|
||||
when (data) {
|
||||
is ContentType -> viewModel.setContentType(data, chip.isChecked)
|
||||
is Boolean -> viewModel.setNewOnly(chip.isChecked)
|
||||
else -> showLocalesMenu(chip)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(item: SourceCatalogItem.Source, view: View) {
|
||||
startActivity(MangaListActivity.newIntent(this, item.source))
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: SourceCatalogItem.Source, view: View): Boolean {
|
||||
viewModel.addSource(item.source)
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
|
||||
viewBinding.tabs.isVisible = false
|
||||
viewBinding.pager.isUserInputEnabled = false
|
||||
val sq = (item.actionView as? SearchView)?.query?.trim()?.toString().orEmpty()
|
||||
viewModel.performSearch(sq)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
|
||||
viewBinding.tabs.isVisible = true
|
||||
viewBinding.pager.isUserInputEnabled = true
|
||||
viewModel.performSearch(null)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun onHasNewSourcesChanged(hasNewSources: Boolean) {
|
||||
private fun updateFilers(
|
||||
appliedFilter: SourcesCatalogFilter,
|
||||
hasNewSources: Boolean,
|
||||
) {
|
||||
val chips = ArrayList<ChipModel>(ContentType.entries.size + 2)
|
||||
chips += ChipModel(
|
||||
title = appliedFilter.locale?.toLocale().getDisplayName(this),
|
||||
icon = R.drawable.ic_language,
|
||||
isDropdown = true,
|
||||
)
|
||||
if (hasNewSources) {
|
||||
if (newSourcesSnackbar?.isShownOrQueued == true) {
|
||||
return
|
||||
}
|
||||
val snackbar = Snackbar.make(viewBinding.pager, R.string.new_sources_text, Snackbar.LENGTH_INDEFINITE)
|
||||
snackbar.setAction(R.string.explore) {
|
||||
NewSourcesDialogFragment.show(supportFragmentManager)
|
||||
}
|
||||
snackbar.addCallback(
|
||||
object : Snackbar.Callback() {
|
||||
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
|
||||
super.onDismissed(transientBottomBar, event)
|
||||
if (event == DISMISS_EVENT_SWIPE) {
|
||||
viewModel.skipNewSources()
|
||||
}
|
||||
}
|
||||
},
|
||||
chips += ChipModel(
|
||||
title = getString(R.string._new),
|
||||
icon = R.drawable.ic_updated_selector,
|
||||
isCheckable = true,
|
||||
isChecked = appliedFilter.isNewOnly,
|
||||
data = true,
|
||||
)
|
||||
snackbar.show()
|
||||
newSourcesSnackbar = snackbar
|
||||
} else {
|
||||
newSourcesSnackbar?.dismiss()
|
||||
newSourcesSnackbar = null
|
||||
}
|
||||
for (type in ContentType.entries) {
|
||||
if (type == ContentType.HENTAI && viewModel.isNsfwDisabled) {
|
||||
continue
|
||||
}
|
||||
chips += ChipModel(
|
||||
title = getString(type.titleResId),
|
||||
isCheckable = true,
|
||||
isChecked = type in appliedFilter.types,
|
||||
data = type,
|
||||
)
|
||||
}
|
||||
viewBinding.chipsFilter.setChips(chips)
|
||||
}
|
||||
|
||||
private fun showLocalesMenu(anchor: View) {
|
||||
val locales = viewModel.locales.mapTo(ArrayList(viewModel.locales.size)) {
|
||||
it to it?.toLocale()
|
||||
}
|
||||
locales.sortWith(compareBy(nullsFirst(LocaleComparator())) { it.second })
|
||||
val menu = PopupMenu(this, anchor)
|
||||
for ((i, lc) in locales.withIndex()) {
|
||||
menu.menu.add(Menu.NONE, Menu.NONE, i, lc.second.getDisplayName(this))
|
||||
}
|
||||
menu.setOnMenuItemClickListener {
|
||||
viewModel.setLocale(locales.getOrNull(it.order)?.first)
|
||||
true
|
||||
}
|
||||
menu.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,19 @@ import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
class SourcesCatalogAdapter(
|
||||
listener: OnListItemClickListener<SourceCatalogItem.Source>,
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) : BaseListAdapter<SourceCatalogItem>(), FastScroller.SectionIndexer {
|
||||
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
||||
|
||||
init {
|
||||
addDelegate(ListItemType.CHAPTER_LIST, sourceCatalogItemSourceAD(coil, lifecycleOwner, listener))
|
||||
addDelegate(ListItemType.HINT_EMPTY, sourceCatalogItemHintAD(coil, lifecycleOwner))
|
||||
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||
}
|
||||
|
||||
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.koitharu.kotatsu.settings.sources.catalog
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
|
||||
data class SourcesCatalogFilter(
|
||||
val types: Set<ContentType>,
|
||||
val locale: String?,
|
||||
val isNewOnly: Boolean,
|
||||
)
|
||||
@@ -1,105 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings.sources.catalog
|
||||
|
||||
import androidx.room.InvalidationTracker
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.ViewModelLifecycle
|
||||
import dagger.hilt.android.lifecycle.RetainedLifecycle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.TABLE_SOURCES
|
||||
import org.koitharu.kotatsu.core.db.removeObserverAsync
|
||||
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
|
||||
class SourcesCatalogListProducer @AssistedInject constructor(
|
||||
@Assisted private val locale: String?,
|
||||
@Assisted private val contentType: ContentType,
|
||||
@Assisted lifecycle: ViewModelLifecycle,
|
||||
private val repository: MangaSourcesRepository,
|
||||
private val database: MangaDatabase,
|
||||
) : InvalidationTracker.Observer(TABLE_SOURCES), RetainedLifecycle.OnClearedListener {
|
||||
|
||||
private val scope = lifecycle.lifecycleScope
|
||||
private var query: String? = null
|
||||
val list = MutableStateFlow(emptyList<SourceCatalogItem>())
|
||||
|
||||
private var job = scope.launch(Dispatchers.Default) {
|
||||
list.value = buildList()
|
||||
}
|
||||
|
||||
init {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
database.invalidationTracker.addObserver(this@SourcesCatalogListProducer)
|
||||
}
|
||||
lifecycle.addOnClearedListener(this)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
database.invalidationTracker.removeObserverAsync(this)
|
||||
}
|
||||
|
||||
override fun onInvalidated(tables: Set<String>) {
|
||||
val prevJob = job
|
||||
job = scope.launch(Dispatchers.Default) {
|
||||
prevJob.cancelAndJoin()
|
||||
list.update { buildList() }
|
||||
}
|
||||
}
|
||||
|
||||
fun setQuery(value: String?) {
|
||||
this.query = value
|
||||
onInvalidated(emptySet())
|
||||
}
|
||||
|
||||
private suspend fun buildList(): List<SourceCatalogItem> {
|
||||
val sources = repository.getDisabledSources().toMutableList()
|
||||
when (val q = query) {
|
||||
null -> sources.retainAll { it.contentType == contentType && it.locale == locale }
|
||||
"" -> return emptyList()
|
||||
else -> sources.retainAll { it.title.contains(q, ignoreCase = true) }
|
||||
}
|
||||
return if (sources.isEmpty()) {
|
||||
listOf(
|
||||
if (query == null) {
|
||||
SourceCatalogItem.Hint(
|
||||
icon = R.drawable.ic_empty_feed,
|
||||
title = R.string.no_manga_sources,
|
||||
text = R.string.no_manga_sources_catalog_text,
|
||||
)
|
||||
} else {
|
||||
SourceCatalogItem.Hint(
|
||||
icon = R.drawable.ic_empty_feed,
|
||||
title = R.string.nothing_found,
|
||||
text = R.string.no_manga_sources_found,
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
sources.sortBy { it.title }
|
||||
sources.map {
|
||||
SourceCatalogItem.Source(
|
||||
source = it,
|
||||
showSummary = query != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
||||
fun create(
|
||||
locale: String?,
|
||||
contentType: ContentType,
|
||||
lifecycle: ViewModelLifecycle,
|
||||
): SourcesCatalogListProducer
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,9 @@ import android.app.Activity
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.view.MenuProvider
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.LocaleComparator
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
|
||||
class SourcesCatalogMenuProvider(
|
||||
@@ -32,14 +27,7 @@ class SourcesCatalogMenuProvider(
|
||||
searchView.queryHint = searchMenuItem.title
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||
R.id.action_locales -> {
|
||||
showLocalesMenu()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = false
|
||||
|
||||
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
|
||||
(activity as? AppBarOwner)?.appBar?.setExpanded(false, true)
|
||||
@@ -57,24 +45,4 @@ class SourcesCatalogMenuProvider(
|
||||
viewModel.performSearch(newText?.trim().orEmpty())
|
||||
return true
|
||||
}
|
||||
|
||||
private fun showLocalesMenu() {
|
||||
val locales = viewModel.locales.mapTo(ArrayList(viewModel.locales.size)) {
|
||||
it to it?.toLocale()
|
||||
}
|
||||
locales.sortWith(compareBy(nullsFirst(LocaleComparator())) { it.second })
|
||||
|
||||
val anchor: View = (activity as AppBarOwner).appBar.let {
|
||||
it.findViewById<View?>(R.id.toolbar) ?: it
|
||||
}
|
||||
val menu = PopupMenu(activity, anchor)
|
||||
for ((i, lc) in locales.withIndex()) {
|
||||
menu.menu.add(Menu.NONE, Menu.NONE, i, lc.second.getDisplayName(activity))
|
||||
}
|
||||
menu.setOnMenuItemClickListener {
|
||||
viewModel.setLocale(locales.getOrNull(it.order)?.first)
|
||||
true
|
||||
}
|
||||
menu.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings.sources.catalog
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.koitharu.kotatsu.core.model.titleResId
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
|
||||
class SourcesCatalogPagerAdapter(
|
||||
listener: OnListItemClickListener<SourceCatalogItem.Source>,
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) : BaseListAdapter<SourceCatalogPage>(), TabLayoutMediator.TabConfigurationStrategy {
|
||||
|
||||
init {
|
||||
delegatesManager.addDelegate(sourceCatalogPageAD(listener, coil, lifecycleOwner))
|
||||
}
|
||||
|
||||
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
||||
val item = items.getOrNull(position) ?: return
|
||||
tab.setText(item.type.titleResId)
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,29 @@
|
||||
package org.koitharu.kotatsu.settings.sources.catalog
|
||||
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.internal.lifecycle.RetainedLifecycleImpl
|
||||
import androidx.room.invalidationTrackerFlow
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.TABLE_SOURCES
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import java.util.EnumMap
|
||||
import java.util.EnumSet
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
@@ -31,41 +31,47 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class SourcesCatalogViewModel @Inject constructor(
|
||||
private val repository: MangaSourcesRepository,
|
||||
private val listProducerFactory: SourcesCatalogListProducer.Factory,
|
||||
private val settings: AppSettings,
|
||||
db: MangaDatabase,
|
||||
settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val lifecycle = RetainedLifecycleImpl()
|
||||
private var searchQuery: String? = null
|
||||
val onActionDone = MutableEventFlow<ReversibleAction>()
|
||||
val locales = repository.allMangaSources.mapToSet { it.locale }
|
||||
val locale = MutableStateFlow(Locale.getDefault().language.takeIf { it in locales })
|
||||
val locales: Set<String?> = repository.allMangaSources.mapTo(HashSet<String?>()) { it.locale }.also {
|
||||
it.add(null)
|
||||
}
|
||||
|
||||
val hasNewSources = repository.observeNewSources()
|
||||
.map { it.isNotEmpty() }
|
||||
private val searchQuery = MutableStateFlow<String?>(null)
|
||||
val appliedFilter = MutableStateFlow(
|
||||
SourcesCatalogFilter(
|
||||
types = emptySet(),
|
||||
locale = Locale.getDefault().language.takeIf { it in locales },
|
||||
isNewOnly = false,
|
||||
),
|
||||
)
|
||||
|
||||
val isNsfwDisabled = settings.isNsfwContentDisabled
|
||||
|
||||
val hasNewSources = repository.observeHasNewSources()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false)
|
||||
|
||||
private val listProducers = locale.map { lc ->
|
||||
createListProducers(lc)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, createListProducers(locale.value))
|
||||
val content: StateFlow<List<ListModel>> = combine(
|
||||
searchQuery,
|
||||
appliedFilter,
|
||||
db.invalidationTrackerFlow(TABLE_SOURCES),
|
||||
) { q, f, _ ->
|
||||
buildSourcesList(f, q)
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
|
||||
|
||||
val content: StateFlow<List<SourceCatalogPage>> = listProducers.flatMapLatest {
|
||||
val flows = it.entries.map { (type, producer) -> producer.list.map { x -> SourceCatalogPage(type, x) } }
|
||||
combine<SourceCatalogPage, List<SourceCatalogPage>>(flows, Array<SourceCatalogPage>::toList)
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
lifecycle.dispatchOnCleared()
|
||||
init {
|
||||
repository.clearNewSourcesBadge()
|
||||
}
|
||||
|
||||
fun performSearch(query: String?) {
|
||||
searchQuery = query
|
||||
listProducers.value.forEach { (_, v) -> v.setQuery(query) }
|
||||
searchQuery.value = query?.trim()
|
||||
}
|
||||
|
||||
fun setLocale(value: String?) {
|
||||
locale.value = value
|
||||
appliedFilter.value = appliedFilter.value.copy(locale = value)
|
||||
}
|
||||
|
||||
fun addSource(source: MangaSource) {
|
||||
@@ -75,21 +81,53 @@ class SourcesCatalogViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun skipNewSources() {
|
||||
launchJob {
|
||||
repository.assimilateNewSources()
|
||||
fun setContentType(value: ContentType, isAdd: Boolean) {
|
||||
val filter = appliedFilter.value
|
||||
val types = EnumSet.noneOf(ContentType::class.java)
|
||||
types.addAll(filter.types)
|
||||
if (isAdd) {
|
||||
types.add(value)
|
||||
} else {
|
||||
types.remove(value)
|
||||
}
|
||||
appliedFilter.value = filter.copy(types = types)
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun createListProducers(lc: String?): Map<ContentType, SourcesCatalogListProducer> {
|
||||
val types = EnumSet.allOf(ContentType::class.java)
|
||||
if (settings.isNsfwContentDisabled) {
|
||||
types.remove(ContentType.HENTAI)
|
||||
}
|
||||
return types.associateWithTo(EnumMap(ContentType::class.java)) { type ->
|
||||
listProducerFactory.create(lc, type, lifecycle).also {
|
||||
it.setQuery(searchQuery)
|
||||
fun setNewOnly(value: Boolean) {
|
||||
appliedFilter.value = appliedFilter.value.copy(isNewOnly = value)
|
||||
}
|
||||
|
||||
private suspend fun buildSourcesList(filter: SourcesCatalogFilter, query: String?): List<SourceCatalogItem> {
|
||||
val sources = repository.getAvailableSources(
|
||||
isDisabledOnly = true,
|
||||
isNewOnly = filter.isNewOnly,
|
||||
excludeBroken = false,
|
||||
types = filter.types,
|
||||
query = query,
|
||||
locale = filter.locale,
|
||||
sortOrder = SourcesSortOrder.ALPHABETIC,
|
||||
)
|
||||
return if (sources.isEmpty()) {
|
||||
listOf(
|
||||
if (query == null) {
|
||||
SourceCatalogItem.Hint(
|
||||
icon = R.drawable.ic_empty_feed,
|
||||
title = R.string.no_manga_sources,
|
||||
text = R.string.no_manga_sources_catalog_text,
|
||||
)
|
||||
} else {
|
||||
SourceCatalogItem.Hint(
|
||||
icon = R.drawable.ic_empty_feed,
|
||||
title = R.string.nothing_found,
|
||||
text = R.string.no_manga_sources_found,
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
sources.sortedBy {
|
||||
it.isBroken
|
||||
}.map {
|
||||
SourceCatalogItem.Source(source = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ class SourcesListProducer @Inject constructor(
|
||||
|
||||
private suspend fun buildList(): List<SourceConfigItem> {
|
||||
val enabledSources = repository.getEnabledSources()
|
||||
val pinned = repository.getPinnedSources()
|
||||
val isNsfwDisabled = settings.isNsfwContentDisabled
|
||||
val isReorderAvailable = settings.sourcesSortOrder == SourcesSortOrder.MANUAL
|
||||
val withTip = isReorderAvailable && settings.isTipEnabled(TIP_REORDER)
|
||||
@@ -75,6 +76,7 @@ class SourcesListProducer @Inject constructor(
|
||||
isEnabled = it in enabledSet,
|
||||
isDraggable = false,
|
||||
isAvailable = !isNsfwDisabled || !it.isNsfw(),
|
||||
isPinned = it in pinned,
|
||||
)
|
||||
}.ifEmpty {
|
||||
listOf(SourceConfigItem.EmptySearchResult)
|
||||
@@ -95,6 +97,7 @@ class SourcesListProducer @Inject constructor(
|
||||
isEnabled = true,
|
||||
isDraggable = isReorderAvailable,
|
||||
isAvailable = false,
|
||||
isPinned = it in pinned,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,10 @@ class SourcesManageFragment :
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemPinClick(item: SourceConfigItem.SourceItem) {
|
||||
viewModel.setPinned(item.source, !item.isPinned)
|
||||
}
|
||||
|
||||
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
|
||||
viewModel.setEnabled(item.source, isEnabled)
|
||||
}
|
||||
|
||||
@@ -58,8 +58,9 @@ class SourcesManageViewModel @Inject constructor(
|
||||
|
||||
fun canReorder(oldPos: Int, newPos: Int): Boolean {
|
||||
val snapshot = content.value
|
||||
if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
|
||||
return (snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled == true
|
||||
val oldPosItem = snapshot.getOrNull(oldPos) as? SourceConfigItem.SourceItem ?: return false
|
||||
val newPosItem = snapshot.getOrNull(newPos) as? SourceConfigItem.SourceItem ?: return false
|
||||
return oldPosItem.isEnabled && newPosItem.isEnabled && oldPosItem.isPinned == newPosItem.isPinned
|
||||
}
|
||||
|
||||
fun setEnabled(source: MangaSource, isEnabled: Boolean) {
|
||||
@@ -71,6 +72,14 @@ class SourcesManageViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setPinned(source: MangaSource, isPinned: Boolean) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
val rollback = repository.setIsPinned(setOf(source), isPinned)
|
||||
val message = if (isPinned) R.string.source_pinned else R.string.source_unpinned
|
||||
onActionDone.call(ReversibleAction(message, rollback))
|
||||
}
|
||||
}
|
||||
|
||||
fun bringToTop(source: MangaSource) {
|
||||
val snapshot = content.value
|
||||
launchJob(Dispatchers.Default) {
|
||||
|
||||
@@ -13,6 +13,7 @@ sealed interface SourceConfigItem : ListModel {
|
||||
val isEnabled: Boolean,
|
||||
val isDraggable: Boolean,
|
||||
val isAvailable: Boolean,
|
||||
val isPinned: Boolean,
|
||||
) : SourceConfigItem {
|
||||
|
||||
val isNsfw: Boolean
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.view.postDelayed
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.MultiSelectListPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.TwoStatePreference
|
||||
@@ -22,6 +23,7 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy
|
||||
import org.koitharu.kotatsu.core.prefs.SearchSuggestionType
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
||||
@@ -29,6 +31,7 @@ import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.util.FileSize
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
@@ -63,6 +66,10 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
|
||||
appShortcutManager.isDynamicShortcutsAvailable()
|
||||
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
|
||||
?.isChecked = !settings.appPassword.isNullOrEmpty()
|
||||
findPreference<ListPreference>(AppSettings.KEY_SCREENSHOTS_POLICY)?.run {
|
||||
entryValues = ScreenshotsPolicy.entries.names()
|
||||
setDefaultValueCompat(ScreenshotsPolicy.ALLOW.name)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
||||
@@ -9,6 +9,8 @@ import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.stats.data.StatsEntity
|
||||
import javax.inject.Inject
|
||||
@@ -62,7 +64,11 @@ class StatsCollector @Inject constructor(
|
||||
|
||||
private fun commit(entity: StatsEntity) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
db.getStatsDao().upsert(entity)
|
||||
runCatchingCancellable {
|
||||
db.getStatsDao().upsert(entity)
|
||||
}.onFailure { e ->
|
||||
e.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user